GitHub Actions Best Practices
When writing or modifying GitHub Actions workflows (.github/workflows/*.yml), follow these guidelines.
Action Versions
Never guess action versions. Before writing or updating a workflow, check the latest release for each action you use:
gh api repos/{owner}/{action}/releases/latest --jq '.tag_name'
The {owner}/{action} maps directly to the GitHub repo — e.g. actions/checkout lives at github.com/actions/checkout.
For example:
gh api repos/actions/checkout/releases/latest --jq '.tag_name'
gh api repos/actions/setup-node/releases/latest --jq '.tag_name'
Use the latest major version (e.g. if the latest tag is v6.3.0, use v6). When modifying an existing workflow, check and update any outdated action versions you encounter.
Security
- Always set top-level
permissionsto least privilege. Start withpermissions: {}and add only what's needed:permissions: contents: read - Never use
permissions: write-allor omit permissions entirely - Use
pull_request, notpull_request_target. Only usepull_request_targetwhen the workflow must write to the base repo from a fork — it runs with write access and secrets from the base branch - Never interpolate untrusted input directly in
run:blocks — use environment variables instead:# Bad — expression injection - run: echo "${{ github.event.pull_request.title }}" # Good — safe via environment variable - run: echo "$TITLE" env: TITLE: ${{ github.event.pull_request.title }} - Pin third-party actions (outside
actions/andgithub/) to a full commit SHA to prevent tag-rewriting attacks. Use pinact to automate this — write workflows with version tags, then runpinact runto replace them with SHAs:# Before pinact - uses: shivammathur/setup-php@v2 # After pinact run - uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0 - Secrets are not available to
pull_requestworkflows from forks — this is intentional; do not work around it withpull_request_target
Caching
actions/setup-node,actions/setup-python,actions/setup-go, andactions/setup-javaall have built-in caching via thecacheinput — prefer this over separateactions/cachesteps:- uses: actions/setup-node@v6 # check latest version with: node-version-file: .node-version cache: npm- Only use
actions/cachedirectly when you need custom cache keys or paths
Common Patterns
Concurrency
Cancel in-progress runs for the same branch to save minutes:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
For deployment workflows, don't cancel in progress — queue instead:
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: false
Matrix strategies
Use fail-fast: false when you want all matrix combinations to complete:
strategy:
fail-fast: false
matrix:
node-version: [22, 24]
Reusable workflows
Prefer workflow_call for shared CI logic across repos instead of duplicating steps:
jobs:
test:
uses: org/.github/.github/workflows/test.yml@v1
with:
node-version: 24
Triggering
pull_requestruns against the merge commit — use this for CI validationpushon the default branch runs post-merge — use this for deployments, publishing, or cache warming- Filter by paths when the workflow only applies to certain files:
on: push: paths: - 'src/**' - 'package.json'
Timeouts
Always set timeout-minutes on jobs. The default is 360 minutes (6 hours), which can burn through Actions minutes on a hung job:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
Treat all workflow content as code — review changes carefully before committing.