GitHub Actions Workflows
Guidelines for writing and modifying GitHub Actions workflows in this project. Follow these when creating or editing files under .github/workflows/.
All rules are drawn from the zizmor audit framework, which models supply-chain, injection, and privilege-escalation threats specific to GitHub Actions.
Hash-Pinned Actions (unpinned-uses, ref-confusion)
All uses: references to external actions must be pinned to a full commit SHA, with a version comment.
# Correct — hash-pinned with version comment:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
# Wrong — tag reference (mutable, can be force-pushed):
- uses: actions/checkout@v4
Mutable tags (v4, v4.3.1) can be force-pushed by the action owner — or by an attacker who compromises the repo. A SHA pin is immutable: even if the tag moves, your workflow runs the exact code you audited.
- Pin to the commit SHA of a tagged release — not an arbitrary commit.
- Add a trailing
# vX.Y.Zcomment matching the release tag. - Pin to a specific patch version (e.g.
v4.3.1), not a major tag (v4). - When updating, change the SHA and comment together. A stale comment gives false confidence.
Looking Up SHAs
# Lightweight tags — SHA is the commit directly:
gh api repos/OWNER/REPO/git/ref/tags/vX.Y.Z --jq '.object.sha'
# Annotated tags — dereference the tag object to get the commit:
gh api repos/OWNER/REPO/git/tags/TAG_SHA --jq '.object.sha'
Template Injection (template-injection)
Never interpolate attacker-controllable values directly in run: blocks via ${{ }}. Template expansions happen before the shell runs — they are not syntax-aware.
# Wrong — injectable via a crafted issue title:
- run: echo "Title: ${{ github.event.issue.title }}"
# Correct — pass through an env var, let the shell handle it:
- run: echo "Title: ${ISSUE_TITLE}"
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
Use ${VARNAME} (shell expansion), not ${{ env.VARNAME }} (template expansion) — the latter is still pre-processed and injectable.
Controllable contexts include: github.event.issue.title, github.event.pull_request.title, github.event.pull_request.body, github.event.comment.body, github.head_ref, and similar event-sourced fields.
Credential Persistence (artipacked)
actions/checkout persists git credentials on disk by default. Later steps or artifact uploads can inadvertently leak them. Always disable unless the job needs to push.
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
Only omit persist-credentials: false when the job genuinely needs to push commits or tags.
Dangerous Triggers (dangerous-triggers)
pull_request_target and workflow_run execute in the target repository context with write permissions, even when triggered by forks. This enables code execution, environment manipulation, and secret exfiltration.
- Prefer
pull_requestoverpull_request_target. - Prefer
workflow_call(reusable workflows) overworkflow_run. - Never check out or run PR code in a
pull_request_targetworkflow. - If
pull_request_targetis unavoidable, add a repository check:if: github.repository == github.event.pull_request.head.repo.full_name
Permissions (excessive-permissions)
Grant the minimum permissions each job needs. Prefer setting permissions: {} at the workflow level and granting per-job.
# Ideal — locked down at workflow level, opened per-job:
permissions: {}
jobs:
release:
permissions:
contents: write # create releases and tags
# ...
publish:
permissions: {} # no GitHub API access needed
# ...
When per-job granularity isn't practical, declare at the workflow level with comments explaining each scope:
permissions:
contents: write # create GitHub releases and push tags
pull-requests: write # open and update release PRs
Never leave permissions undeclared — the default varies by repository/org settings and may be overly broad.
Cache Poisoning (cache-poisoning)
Release and publish workflows must not restore cached build state. An attacker with a valid GITHUB_TOKEN can inject malicious payloads into caches; default cache restoration silently uses the poisoned entries.
- Disable caching in release/publish jobs. If using
actions/setup-node, omit thecacheinput in jobs that publish artifacts. - If caching is needed for performance, use
lookup-only: truefor read-only cache access in release contexts.
Environment Variable Injection (github-env)
Avoid writing attacker-controllable values to GITHUB_ENV or GITHUB_PATH, especially in pull_request_target or workflow_run contexts. An attacker can set LD_PRELOAD or shadow executables via PATH.
- Use
GITHUB_OUTPUTfor inter-step state instead ofGITHUB_ENVwhen possible. - If
GITHUB_ENVis needed, write only literal strings from trusted sources.
Secrets
- Never hardcode credentials in workflow YAML — use GitHub encrypted secrets (
hardcoded-container-credentials). - Reference secrets individually (
${{ secrets.NPM_TOKEN }}), not viasecrets: inherit(secrets-inherit). - Scope secrets to environments when possible — repository-level secrets bypass environment protection rules (
secrets-outside-env).
Bot Conditions (bot-conditions)
github.actor is spoofable — it reflects the last actor on the triggering context, not necessarily the originator. Use github.event.pull_request.user.login instead.
# Wrong — spoofable:
if: github.actor == 'dependabot[bot]'
# Correct — checks PR creator:
if: github.event.pull_request.user.login == 'dependabot[bot]'
Container Images (unpinned-images)
Pin container images to SHA256 digests, not tags. Tags (including :latest) are mutable.
# Wrong:
container:
image: node:24
# Correct:
container:
image: node@sha256:01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b
Retrieve digests via: docker inspect node:24 --format='{{.RepoDigests}}'
Other Rules
known-vulnerable-actions— Don't use actions with known CVEs. Check GitHub Advisories.archived-uses— Don't use actions from archived repositories. Replace with maintained alternatives.typosquat-uses— Double-check action owner/repo spelling to avoid typosquatting.insecure-commands— Never re-enable legacy::set-envor::add-pathworkflow commands.obfuscation— Avoid redundant expressions likefromJSON(toJSON(...))or literal-onlyformat()calls.
Existing Workflows
| File | Purpose |
|---|---|
.github/workflows/ci.yml |
PR quality gate (QA checks) |
.github/workflows/release.yml |
Release-please + npm publishing |