GitHub Actions workflow best practices and pitfalls reference. Use when: (1) Writing or reviewing .yml workflows, (2) Setting up CI/CD pipelines, (3) Debugging slow, expensive, or stuck workflow runs, (4) User says 'gh actions', 'github actions', 'workflow best practices', (5) Before creating or modifying any .github/workflows/ file. Keywords: GitHub Actions, CI/CD, workflow, timeout, concurrency, security, caching.
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.
GitHub Actions workflow best practices and pitfalls reference. Use when: (1) Writing or reviewing .yml workflows, (2) Setting up CI/CD pipelines, (3) Debugging slow, expensive, or stuck workflow runs, (4) User says 'gh actions', 'github actions', 'workflow best practices', (5) Before creating or modifying any .github/workflows/ file. Keywords: GitHub Actions, CI/CD, workflow, timeout, concurrency, security, caching.
GitHub Actions Wisdom
Reference best practices before writing or reviewing any GitHub Actions workflow.
Load topic-specific references as needed from references/.
Runner Context Matters
Several rules below depend on whether your jobs run on ephemeral cloud runners (GitHub-hosted ubuntu-latest, RunsOn, BuildJet, Namespace, etc. โ fresh VM per job, wiped between runs) or persistent self-hosted runners (long-lived machines with state that carries across runs). Advice that is correct in one context can be a hard-to-debug bug in the other.
Concern
Ephemeral cloud runners
Persistent self-hosted runners
actions/cache for build tools
Use it โ disk is wiped between runs
Avoid โ local disk is already the cache
set-safe-directory: false
Don't set โ containers need the default
Set it โ avoids ~/.gitconfig pollution
Manual workspace cleanup steps
Not needed โ fresh VM each run
Often needed โ workspace persists
chown workspace at job end
Not needed โ VM is destroyed
Sometimes needed for next-run access
detect-runner fallback pattern
Obsolete โ the cloud runner IS the runner
Useful when mixing self-hosted + GitHub-hosted
Migration warning. When moving a workflow from self-hosted to ephemeral (or vice versa), audit every step and option that was added "for the runner". Leftover self-hosted-isms on a cloud runner produce mysterious failures: pnpm: command not found (no setup step because pnpm was preinstalled), Cache not found between jobs (cache backend differs), fatal: detected dubious ownership (because set-safe-directory: false is now actively wrong), etc. Specific rules below are gated by runner context where it matters.
Critical Rules (Always Apply)
1. Always Set timeout-minutes
The default timeout is 360 minutes (6 hours). A stuck job silently burns runner minutes.
jobs:build:runs-on:ubuntu-latesttimeout-minutes:15# ALWAYS set this
Recommended values:
Job type
timeout-minutes
Lint / typecheck
5-10
Unit tests
10-15
Build
15-30
E2E tests
30-60
Docker build
15-30
Deploy
10-15
Notification
5
2. Always Set Concurrency Control
Prevent redundant runs and protect production deploys.
# PR checks: cancel previous runs on new pushconcurrency:group:${{github.workflow}}-${{github.ref}}cancel-in-progress:true# Production deploy: never cancel in-progressconcurrency:group:deploy-productioncancel-in-progress:false
3. Always Declare Permissions
Never rely on default permissions. Declare explicitly per workflow or per job.
Tags are mutable. The March 2025 tj-actions/changed-files supply chain attack (CVE-2025-30066) compromised 23,000+ repos via rewritten tags.
# Bad - tag can be rewritten-uses:actions/checkout@v4# Good - immutable SHA-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2
Caveat: Some repos (e.g., pnpm/action-setup) have force-pushed, invalidating previously pinned SHAs. If CI fails with Unable to resolve action ... unable to find version, look up the current SHA via gh api repos/OWNER/REPO/git/ref/tags/vX.Y.Z. See references/security.md for the full diagnostic procedure.
5. Do NOT Cache Package Managers (pnpm/npm/yarn)
Do not use cache: 'pnpm' (or cache: 'npm', cache: 'yarn') in actions/setup-node. GitHub Actions cache restore is often slower than a fresh pnpm install from npm's CDN. npm's CDN is highly optimized for package downloads, while GitHub's cache API has significant overhead for large stores (especially 1GB+). Benchmarking confirmed: direct install from CDN consistently beats cache restore + install.
# BAD - cache restore adds overhead, slower than fresh install-uses:actions/setup-node@v4with:node-version-file:.node-versioncache:pnpm# REMOVE THIS# GOOD - just install directly-uses:actions/setup-node@v4with:node-version-file:.node-version-run:pnpminstall
This is especially true for self-hosted runners where the pnpm store is already local โ caching to GitHub's remote cache and restoring it is pointless overhead.
6. set-safe-directory: leave default on ephemeral runners, set false on self-hosted
actions/checkout defaults set-safe-directory to true, which runs git config --global --add safe.directory on every run.
Ephemeral cloud runners โ leave the default (true). Each run is a fresh VM, so there is no gitconfig to pollute. The default is also required for container jobs whose UID differs from the host runner user; without it, git inside the container errors with fatal: detected dubious ownership when it tries to operate on the mounted workspace.
# GOOD on ephemeral runners โ let checkout do its default thing-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2
Persistent self-hosted runners โ set it to false. Otherwise ~/.gitconfig accumulates a duplicate safe.directory entry on every run, polluting the shared gitconfig across every repo on that machine.
# GOOD on self-hosted runners โ prevent gitconfig pollution-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2with:set-safe-directory:false
When migrating self-hosted โ ephemeral, forgetting to remove set-safe-directory: false is a common gotcha. Non-container jobs may still work (the runner user owns the workspace), but the moment a job runs in a container, git inside hits dubious-ownership and fails with confusing errors.
Container jobs need an extra manual step (regardless of runner type)
actions/checkout (a node action) writes safe.directory to the node-action HOME (/root/.gitconfig inside many containers). Shell run: steps inside the container have a different HOME (/github/home), so they read a different gitconfig and don't see the safe.directory entry. Lifecycle scripts (pnpm install calling prepare โ lefthook install โ git) then fail with fatal: detected dubious ownership.
For container jobs, add a manual step before checkout that writes safe.directory to the shell-side gitconfig:
test:runs-on:ubuntu-latestcontainer:image:foo:barsteps:-name:Markworkspaceassafeforgitrun:gitconfig--global--addsafe.directory"$GITHUB_WORKSPACE"-uses:actions/checkout@v4# ... rest of the job
This is orthogonal to the set-safe-directory option โ it covers shell-step git invocations, which checkout's option doesn't reliably reach in container jobs. Plain (non-container) jobs do not need it.
7. actions/cache for build tools: yes on ephemeral, no on self-hosted
Persistent self-hosted runners โ build tool caches (Cargo, Go modules, Gradle, etc.) already persist on the runner's local disk. Using actions/cache uploads them to GitHub's remote cache API on every run and creates duplicate entries, wasting storage.
# BAD on self-hosted โ uploads local cache to remote on every run-uses:actions/cache@v4with:path:~/.cargo/registrykey:cargo-${{hashFiles('Cargo.lock')}}# GOOD on self-hosted โ just use the local disk cache directly# (no actions/cache step needed)
Ephemeral cloud runners โ disk is wiped between runs, so actions/cache is essential to avoid re-downloading the dependency tree from scratch every time. Use it for ~/.cargo/registry, ~/.gradle/caches, the Go module cache, etc.
# GOOD on ephemeral runners โ survives across runs-uses:actions/cache@v4with:path:~/.cargo/registrykey:cargo-${{runner.os}}-${{hashFiles('Cargo.lock')}}
Note: rule 5 ("Don't cache package managers in setup-node") still applies on both runner types โ that rule is about npm package downloads where the CDN is faster than cache restore. Rule 7 is about general build-tool caches.
8. Avoid curl | sh Installers โ Use Prebuilt-Binary Actions
Installer scripts like curl https://.../init.sh | sh (wasm-pack, rustup, many language toolchains) do one HTTP request with no retry. A single transient 5xx from the redirect target (e.g., a GitHub release asset) kills the entire workflow. Seen in the wild: rustwasm.github.io โ github.com/rustwasm/wasm-pack/releases/... returning 504 mid-deploy.
# BAD โ one curl, no retry, fails on any 5xx-name:Installwasm-packrun:curlhttps://rustwasm.github.io/wasm-pack/installer/init.sh-sSf|sh# GOOD โ prebuilt binary from GitHub releases, with retries + runner caching-uses:taiki-e/install-action@v2with:tool:wasm-pack
taiki-e/install-action covers most Rust/Go/Node tools (wasm-pack, cargo-nextest, just, mdbook, etc.). For tools it doesn't cover, use actions/cache on a pinned-version binary, or wrap the curl in a retry loop with curl --retry 5 --retry-all-errors --retry-delay 5.
9. Use Cache (Not Artifacts) for Inter-Job Data Sharing
upload-artifact/download-artifact counts toward shared org storage (often limited). For passing build output between jobs in the same workflow, use actions/cache instead โ it has a separate 10 GB per-repo limit.
# BAD โ artifacts accumulate in shared org storage-uses:actions/upload-artifact@v4with:name:build-outputpath:dist/retention-days:1# GOOD โ cache uses separate per-repo quota-uses:actions/cache/save@v4with:path:dist/key:build-${{github.run_id}}# In the downstream job:-uses:actions/cache/restore@v4with:path:dist/key:build-${{github.run_id}}
If the build and deploy steps can run on the same runner, merging them into a single job is even simpler.
Caveat for cloud runners that proxy the cache layer (e.g., RunsOn with extras=s3-cache+magic-cache) โ the runner injects a sidecar at ACTIONS_RESULTS_URL that intercepts both v2 cache and v4 artifact API calls. The sidecar speaks the cache protocol but does not always speak the v4 artifact protocol. With magic-cache enabled, actions/upload-artifact@v4 may fail with Unexpected token '...' is not valid JSON because the sidecar returns plain-text errors for artifact endpoints.
If you hit this, the symptoms vary by transport:
Cache-based passing across instances: works only if the sidecar is reachable. From inside a container job whose docker network is isolated from the runner host, the sidecar's host IP is unreachable โ Cache not found even when the upstream job successfully saved.
Artifact-based passing: works if you remove the proxy interception (drop magic-cache from the runs-on label) so v4 artifact calls reach api.github.com directly.
When in doubt on a cloud runner that proxies caching, prefer upload-artifact/download-artifact over actions/cache and disable any cache-proxy extras. Artifacts go straight to the GitHub API which is reachable from any container or instance.
Quick Reference by Topic
For detailed guidance, read the appropriate reference file:
Never debug CI issues by pushing and waiting. CI runs consume time (10-15 min per cycle) and runner minutes. Always verify locally first:
# Run the same checks CI runs, locally
pnpm check # typecheck + lint + format
pnpm build # production build
pnpm test# unit tests# Only after ALL pass locally:
git push
# Then monitor:
/watch-ci
The workflow: fix locally โ verify locally โ push once โ /watch-ci. If CI fails after local verification, it's either an environment difference (Node version, missing env vars) or a path/dependency issue specific to CI โ much easier to diagnose than a code bug.
Workflow Review Checklist
When reviewing or writing a workflow, verify:
Every job has timeout-minutes
concurrency group is set with appropriate cancel-in-progress
permissions are declared (least privilege)
Third-party actions pinned to SHA with version comment
pull_request_target is NOT used with PR code checkout
No string interpolation of user-controlled values in run: blocks
Secrets passed individually, not via secrets: inherit
No cache: parameter in setup-node (fresh install from CDN is faster โ see rule 5)
Path filters used where possible to skip irrelevant runs
Deploy steps have retry logic for network operations
actions/checkout matches the runner type โ default on ephemeral, set-safe-directory: false on self-hosted only (see rule 6)
actions/cache for build tools matches the runner type โ used on ephemeral, NOT on self-hosted (see rule 7)
No curl | sh installers โ use taiki-e/install-action or similar with retries (see rule 8)
Inter-job data sharing uses actions/cache not upload-artifact to avoid org storage limits โ but switch to artifacts when a cloud runner's cache-proxy sidecar (e.g. RunsOn magic-cache) breaks v4 caching from container jobs (see rule 9)
When migrating between self-hosted and ephemeral runners, audit every step for runner-type-specific options that may now be wrong (see "Runner Context Matters")