Best Practices and Anti-Patterns
Topic 72

GitHub Actions Best Practices

Best Practices

A CI/CD pipeline runs your code with your tokens, on every push, often with permission to write to the repo and reach your cloud. That makes Actions hygiene a security posture, not a tidiness preference: the same workflow that ships your release is the one an attacker most wants to compromise. The disciplines here keep that pipeline both secure and fast.

This page consolidates the Actions concepts from earlier chapters into the configuration you actually commit to .github/workflows. The unifying idea is least privilege and least trust — pin what runs, scope what it can touch, and prefer short-lived identity over stored keys.

Pinning Third-Party Actions

Pin every third-party action to a full commit SHA, not a mutable tag like @v4. A tag is a pointer the action's maintainer (or an attacker who compromises the repo) can move, and when they do, that code runs in your pipeline with your tokens. The 2025 tj-actions compromise did exactly this: a hijacked tag exfiltrated secrets across thousands of consumers who had pinned to a tag. A SHA is immutable, so what you reviewed is what runs.

Least-Privilege GITHUB_TOKEN

The default GITHUB_TOKEN is over-scoped for most jobs. Set permissions: {} at the top of the workflow and grant each job only what it needs — usually contents: read. Without this, a single compromised step or malicious dependency can push to the repo, move tags, or publish a release using a token you handed it by default.

Caching That Actually Hits

Key caches on the hash of your dependency lockfile, with sensible restore-keys for partial fallbacks. A cache keyed on a constant never invalidates, so it serves stale dependencies that poison builds; one keyed too finely (on a timestamp or commit SHA) never hits, so every run pays full install cost. The lockfile hash is the key that changes exactly when dependencies change.

Reusable Workflows and Composite Actions

Extract shared pipeline logic into workflow_call reusable workflows and composite actions so thirty repositories do not each maintain a divergent copy of the deploy job. When the logic lives in one place, a fix is one edit; when it is copy-pasted, a fix is thirty edits that drift the moment one is missed.

Secrets and OIDC

Never echo secrets into logs, and prefer OIDC for cloud authentication over storing long-lived keys. Scope deploy secrets to protected Environments so they require approval, and remember that pull requests from forks cannot read secrets by design — a deliberate guardrail you should not work around. OIDC mints a short-lived token per run, scoped to the repository and environment, so there is no standing credential to leak.

Concurrency and Cost Control

Use a concurrency group to cancel superseded runs so five pushes in a minute do not run five full pipelines, add path filters so a docs change does not trigger the full test matrix, and set timeout-minutes so a hung job fails in minutes instead of burning an hour of runner time. These keep the pipeline fast and the bill predictable.

OIDC Federation vs Stored Cloud Secrets

OIDC federation — the workflow presents a signed identity token and the cloud exchanges it for a short-lived credential scoped to that repository and environment. Nothing long-lived is stored, so a leaked log line is worthless minutes later.

Stored cloud secrets — a long-lived access key sits in repository secrets and is injected at runtime. Convenient and provider-agnostic, but the full blast radius of that key is exposed the moment it leaks, and rotation is a manual chore.

Common Mistakes
  • Pinning actions to @v4 or @main — a hijacked tag silently exfiltrates secrets across every consumer, as documented supply-chain attacks have shown.
  • Leaving the default broad GITHUB_TOKEN permissions — a compromised step can push to the repo, move tags, or cut a release.
  • Caching node_modules on a non-lockfile key — either stale dependencies poison the build or the cache never hits and every run reinstalls.
  • Putting secrets in top-level workflow env where every step can read them — including code reached via pull_request_target.
  • Copy-pasting the same 200-line deploy job into 20 repos — one fix means 20 edits, and the copies drift until they behave differently.
Best Practices
  • Pin every third-party action to a full commit SHA and use Dependabot to bump them safely.
  • Declare permissions: explicitly and minimally, defaulting to contents: read.
  • Key caches on the dependency lockfile hash with restore-keys fallbacks.
  • Extract shared pipelines into workflow_call reusable workflows and composite actions.
  • Authenticate to clouds with OIDC and scope deploy secrets to protected Environments.
  • Use concurrency, path filters, and timeout-minutes to keep runs fast and costs bounded.
Comparable toolsGitLab CI include, protected environments, ID tokens for OIDCCircleCI orbs and contexts for shared config and scoped secretsJenkins shared libraries for reusable pipeline logic

Knowledge Check

Why does pinning an action to a commit SHA beat pinning to a tag?

  • A tag is mutable and can be moved to point at attacker code, while a SHA is immutable — what you reviewed is exactly what runs
  • A full commit SHA resolves and downloads measurably faster than a moving tag does on every workflow run
  • Tags have been formally deprecated by GitHub and will stop resolving inside Actions workflows entirely in some near-future release
  • Pinning to a full SHA grants the called action strictly fewer token permissions than a tag would

What does an over-scoped GITHUB_TOKEN enable if a step is compromised?

  • Pushing to the repo, moving tags, or publishing releases — actions far beyond what the job needed
  • Reading the full source contents of every other private repository across the entire surrounding organization
  • Nothing harmful at all, because the workflow token is strictly read-only by default
  • Turning off the branch protection rules that are configured on the main branch

What is correct cache key design?

  • Key on the dependency lockfile hash with restore-keys fallbacks, so the cache invalidates exactly when dependencies change
  • Key on a single fixed constant string so that the dependency cache reliably scores a hit on absolutely every single workflow run
  • Key on the current commit SHA so that every single run is guaranteed its own fresh cache entry
  • Key on the current build timestamp to guarantee complete dependency freshness on each run

How does OIDC differ from a stored cloud secret?

  • OIDC mints a short-lived token per run scoped to the repo and environment; a stored secret is a long-lived key with full blast radius if leaked
  • OIDC stores the cloud key encrypted at rest while a secret stores it in plaintext, so the difference is purely whether the same standing credential is wrapped in a layer of encryption before it sits in the repository settings
  • OIDC works only with clouds that GitHub itself hosts and operates, so a deploy targeting any outside provider still has to fall back to a stored long-lived key in secrets
  • There is no real security difference; OIDC is just newer configuration syntax for the same underlying key, relabeling how you reference the credential without changing how long it lives

When do reusable workflows pay off?

  • When the same pipeline logic is needed across many repos — one workflow_call source means a fix is one edit instead of many drifting copies
  • Only for the narrow set of workflows that happen to be triggered on a fixed recurring cron schedule
  • When a single isolated repository happens to contain exactly one workflow file and shares no pipeline logic at all with any other repo anywhere
  • They are strictly required for any workflow that needs to consume one or more repository secrets

You got correct