GitHub Actions Footguns¶
Mistakes that expose secrets, break pipelines, or silently skip work you thought was running.
1. Long-Lived Cloud Credentials Stored as Secrets¶
You add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as repository secrets. They're static IAM credentials with broad permissions. When a maintainer's account is compromised or a malicious PR runs your workflow, the attacker has your cloud access indefinitely.
Fix: Use OIDC federation instead. Configure your cloud provider to trust GitHub's OIDC token issuer, create an IAM role with a trust policy scoped to your repo and branch, then use aws-actions/configure-aws-credentials with role-to-assume. No static credentials anywhere.
2. pull_request_target with Checkout of the Fork's Code¶
You use pull_request_target to access secrets in PR workflows (since pull_request from forks doesn't get secrets). You then check out the PR's head commit and run it. A malicious contributor submits a PR that modifies your workflow or build scripts to exfiltrate secrets.
Fix: pull_request_target runs with write permission and secrets access — never check out untrusted code in this context. Only use pull_request_target for labeling, commenting, or safe read operations. Separate the privileged part (deployment) from the build/test part using workflow artifacts and pull_request events.
CVE: This attack vector (dubbed "pwn requests") has been used to compromise major open source projects including Apache, Eclipse, and multiple CNCF projects. GitHub's own security blog documented the pattern in 2021. If your workflow uses
pull_request_target+actions/checkout@PR-ref, assume it's exploitable.
3. cancel-in-progress: true on Deployment Workflows¶
You set cancel-in-progress: true on your deploy workflow so new pushes cancel old ones. A deploy is halfway through migrating a database when a second push cancels it. Your database is in a half-migrated state and your app crashes.
Fix: Never use cancel-in-progress: true on workflows that mutate production state. Use it on CI/test workflows. For deployments, use cancel-in-progress: false to queue instead of cancel, or use a deployment system with rollback capability outside of GitHub Actions.
Gotcha:
concurrencygroups are scoped by the group name string. If you useconcurrency: group: deploy-${{ github.ref }}, each branch gets its own group — pushes tomainonly cancel othermaindeploys. But if you useconcurrency: group: deploy, ALL branches share one group and pushes to feature branches can cancel production deploys.
4. Secrets Printed to Logs¶
A step uses run: echo "Connecting to ${{ secrets.DB_URL }}" for debugging. GitHub redacts known secret values in logs, but only exact matches — if the secret is base64-encoded, URL-encoded, or embedded in a longer string, it leaks in plaintext.
Fix: Never interpolate secrets directly into run commands or debug output. Pass secrets as environment variables and let the tool consume them: env: { DB_URL: ${{ secrets.DB_URL }} }. Remove all debug echo statements before merging. If a secret leaks, rotate it immediately — logs are often cached by third-party services.
5. Unpinned Third-Party Actions¶
You use uses: some-org/some-action@v2. The action maintainer pushes a new commit to the v2 tag (tag mutation is allowed on GitHub). Your workflow now runs attacker-controlled code with access to all your secrets.
Fix: Pin all third-party actions to a full commit SHA: uses: some-org/some-action@abc1234def5678. Use Dependabot or Renovate to manage action updates with automated PRs. Only use SHA pins for actions outside your org; your own org's actions can use branch refs.
War story: The
codecov/codecov-actionsupply chain attack (2021) injected credential-stealing code into the action. Users pinned to@v1(mutable tag) got the compromised version. Users pinned to a SHA were unaffected. This incident drove widespread adoption of SHA pinning for GitHub Actions.
6. Cache Key Collision Across Branches¶
You use key: ${{ runner.os }}-deps without any content hash. Every branch shares the same cache. A branch that installs a bad dependency version poisons the cache for all other branches, causing intermittent failures that are hard to trace.
Fix: Always include a content hash in your cache key: key: ${{ runner.os }}-deps-${{ hashFiles('**/package-lock.json') }}. Add restore-keys as a fallback for partial matches. Never use a static key without a content fingerprint.
Under the hood: GitHub Actions cache entries are scoped to the branch, then fall back to the default branch. A cache created on
mainis readable from feature branches, but not vice versa. This means a poisoned cache onmainaffects all branches, but a feature branch can't poisonmain.
7. Matrix fail-fast: true Hiding Root Causes¶
The default fail-fast: true means when one matrix leg fails, GitHub cancels the remaining legs immediately. You see one failure, fix it, re-run, and discover three more failures. You iterate five times instead of seeing all failures in a single run.
Fix: For test matrices, set fail-fast: false so all legs complete and you see the full failure picture. Only use fail-fast: true when all legs are identical and one failure genuinely invalidates the others (e.g., parallel shards of the same test suite).
8. workflow_run Depends on Workflow Name, Not File¶
You rename a workflow file from ci.yml to tests.yml but forget to update the workflow_run trigger in your deployment workflow. The deployment workflow stops triggering because it references the old workflow name string, not the file path. No error is thrown.
Fix: After renaming any workflow file, grep for its display name (name: field at the top) in all other workflow files and update workflow_run.workflows references. Test with gh workflow run to verify the trigger chain is intact.
9. if: always() on Cleanup Steps That Can Expose State¶
You add if: always() to a cleanup step so it runs even after failures. The cleanup step runs docker push or deploys an artifact even when the build failed — shipping a broken image because you wanted to ensure cleanup ran.
Fix: Be precise with conditions. if: always() means "run even if previous steps failed." For actual cleanup (removing temp files, stopping services), use if: always(). For publishing, deploying, or anything downstream, use if: success() (the default) or if: needs.build.result == 'success'. Consider separating cleanup into explicit post steps using composite actions.
10. Workflow Files in Fork PRs Can See GITHUB_TOKEN Scope Expansion¶
You think GITHUB_TOKEN in a fork's PR is read-only, so you give your workflow permissions: write-all. The token scope applies at the workflow level — if the workflow grants write permissions and runs on pull_request from a fork, the forked code can use the token to write to your repo.
Fix: Always declare minimum necessary permissions explicitly. Use permissions: read-all at the workflow level and grant write only at the job level for jobs that need it. Never use permissions: write-all as a blanket default.
Default trap: Before February 2023, GitHub's default
GITHUB_TOKENpermissions werewrite-allfor all events. Repos created before this date still have the old default unless an admin changed it. Check at Settings > Actions > General > Workflow permissions. New repos default to read-only, but inherited org settings may override this.