Actions Authoring
Security first — the three that actually get exploited
1. pull_request_target with a PR-head checkout
# CRITICAL — a fork PR gets write-scoped secrets
on: pull_request_target
jobs:
test:
steps:
- uses: actions/checkout@v4
with: { ref: ${{ github.event.pull_request.head.sha }} }
- run: npm test # attacker's code, your secrets
pull_request_target runs in the base repo's context with secrets. Checking
out the PR head executes attacker-controlled code with those secrets in scope.
Use pull_request and accept that secrets are unavailable — that is the point.
2. Script injection through ${{ }}
# wrong — issue title is attacker-controlled, interpolated into the shell
- run: echo "Title: ${{ github.event.issue.title }}"
# right — passed as an environment variable
- env:
TITLE: ${{ github.event.issue.title }}
run: echo "Title: $TITLE"
Any github.event.* field a user can write is untrusted: title, body, branch
name, commit message, label name.
3. Unpinned third-party actions
Tags are mutable. Pin to a commit SHA with the version in a trailing comment:
- uses: some-org/some-action@8f4b7c2e9a1d... # v3.1.0
First-party actions/* at a major tag is the common accepted exception — decide
deliberately rather than by default.
Permissions
Declare explicitly. The default GITHUB_TOKEN scope is far wider than any
single workflow needs.
permissions:
contents: read # workflow-level floor
jobs:
release:
permissions:
contents: write # narrowed per job
id-token: write # OIDC
Use OIDC for cloud auth. Long-lived cloud keys in secrets are a standing liability that no rotation policy fully fixes.
Caching
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-
Two failure modes, both common:
- A key containing
github.run_id, a timestamp, or the commit sha can never hit — it costs upload time every run and saves nothing. - A key with no lockfile hash never misses when it should, restoring a stale dependency tree after an upgrade.
Check the actual hit rate in the restore step's log before tuning anything.
Concurrency
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
Never cancel in progress on the default branch — that kills the run that gates deploys.
Matrices
Full OS × version matrix on every PR is usually waste. Run a representative slice on PRs and the full matrix on the default branch and on a schedule.
strategy:
fail-fast: false # you want all failures, not the first
matrix:
node: [20, 22]
include:
- { node: 24, os: ubuntu-latest, experimental: true }
Cost levers, ordered by typical payoff
Cache hit rate → job graph (unnecessary needs:) → matrix pruning →
concurrency groups → path filters → fail-fast ordering (cheap checks first) →
runner sizing → artifact retention.
Never buy speed by dropping coverage. A removed matrix entry that covers a supported platform is not an optimization.
See also
ci-forensics— diagnosing failures../commands/audit.md— repo-wide workflow security audit