# GitHub Actions job has been skipped (needs)

> A GitHub Actions job has been skipped (needs) because a job it depends on did not succeed. Read the result table, then pick the condition you meant.

Source: https://latchkey.dev/learn/github-actions/github-actions-job-skipped-needs-result  
Updated: 2026-09-20

A GitHub Actions job has been skipped (needs) whenever a job named in its `needs` list did not succeed, and that includes an upstream job that was itself skipped. There is no error to read, because nothing failed: the default condition on every job is a success check, and a skip satisfies it no better than a failure does.

## What this error means

The run is green or red depending on what else happened, and one job in the graph never started. It shows as skipped in the run summary, with no annotation, no log and no steps to open. The job that skipped is usually the deploy, the publish or the notify, so the first symptom is often that something did not happen rather than that something went wrong. When the skip is at the top of a chain the whole tail goes with it, one job per level, each with the same blank page. The only place the reason is written down is the result of the job it depends on, which the `needs` context carries for every direct dependency and which you can print from any job that declares that dependency.

```GET /actions/runs/35502021128/jobs on cli/cli, read 2026-09-20
{
  "name": "stale",
  "status": "completed",
  "conclusion": "skipped",
  "steps": []
}
```

## Common causes

### An upstream job was skipped by its own condition

The ordinary case. A job gated on a branch, an event or a path filter evaluates false, and the skip travels down every edge of the graph that names it. Nothing failed, so nothing is red, and the run summary looks healthy while the job you cared about never ran.

### An upstream job failed, and you expected a partial run

A failure and a skip produce the same downstream behavior under the default condition, which is why reports of this arrive describing both. If you wanted the notify job to run precisely because the build failed, the default success check is the thing standing in the way.

### The job reads a result two levels up

The `needs` context only holds direct dependencies. A job that names `test` but reads `needs.build.result` gets an empty value rather than an error, because dereferencing a property that is not there "will evaluate to an empty string" per the contexts reference. The comparison then quietly fails and the job skips.

### The condition uses always() and now runs on cancellation too

The fix applied in a hurry has its own failure mode. `always()` returns true "even when canceled", so a deploy guarded with it will start after somebody cancels the run. It is the most expensive version of this problem, because it turns a skipped job into a job that should not have run at all.

## How to fix it

### Decide which of the four results should let the job run

1. Write down what you want for each of `success`, `failure`, `cancelled` and `skipped` on the upstream job.
2. Pick the override: `!cancelled()` for almost everything, `always()` only when a cancelled run should still do this work.
3. Add the comparison on `needs.<job>.result` that expresses the rest.

### Run the job unless the run was cancelled

The common intent: this job should run whether the dependency passed or was skipped, but not if the whole run was cancelled and not if the dependency failed. The two halves are separate and both are needed, because the status function only removes the default success check.

```.github/workflows/release.yml (illustrative)
package:
    needs: [build, test]
    if: ${{ !cancelled() && needs.build.result != 'failure' }}
    runs-on: ubuntu-latest
    steps:
      - run: ./package.sh
```

### Print the results before you guess

One step is enough to turn this into a five minute problem. Add a job that depends on the same set and dumps the context, run it once, and the four values are in front of you instead of inferred from the graph.

```.github/workflows/release.yml (illustrative)
debug:
    needs: [build, test]
    if: ${{ always() }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "$NEEDS"
        env:
          NEEDS: ${{ toJSON(needs) }}
```

### Stop the skip at the source where you can

A job that exists only to be gated is often better expressed as a gate inside the job. Moving the condition from the job to the steps keeps the job green rather than skipped, which keeps every dependant running and keeps a required check from sitting pending on a protected branch.

```.github/workflows/release.yml (illustrative)
build:
    runs-on: ubuntu-latest
    steps:
      - if: github.ref == 'refs/heads/main'
        run: ./build.sh
```

## How to prevent it

- Treat `needs` without an `if` as a declaration that every dependency must succeed, because that is what it is.
- Prefer `!cancelled()` over `always()`, and keep `always()` for jobs that must run after a cancellation.
- Read `needs.<job>.result` explicitly rather than relying on the default check to mean what you want.
- Name every job whose result you read, because the context holds direct dependencies only.

## A minimal workflow that produces it

This file is written for this page and has never been run. `build` is gated on a branch, so on any other branch it is skipped rather than failed. `deploy` names it in `needs` and writes no condition of its own, so it inherits the default success check and skips too. Nothing here is red, and nothing prints a reason.

```.github/workflows/release.yml (illustrative)
name: release
on: [push]

jobs:
  build:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - run: echo building

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - run: echo deploying
```

## What GitHub says happens, in its own words

The rule is one sentence in the workflow syntax reference for `jobs.<job_id>.needs`: "If a job fails or is skipped, all jobs that need it are skipped unless the jobs use a conditional expression that causes the job to continue." The next sentence explains the chain you are looking at: "If a run contains a series of jobs that need each other, a failure or skip applies to all jobs in the dependency chain from the point of failure or skip onwards."

The second half of the mechanism is in the expressions reference: "A default status check of `success()` is applied unless you include one of these functions." That default is why a job with no `if` at all still has a condition, and why adding any status function changes the behavior of a job you thought you had not touched.

Those two rules give one grid: the result of the job you depend on down the left, the condition you wrote across the top.

| `needs.build.result` | no `if` | `always()` | `!cancelled()` | plus `result != 'failure'` |
| --- | --- | --- | --- | --- |
| `success` | runs | runs | runs | runs |
| `failure` | skipped | runs | runs | skipped |
| `cancelled` | skipped | runs | skipped | skipped |
| `skipped` | skipped | runs | runs | runs |

## The result you branch on, and the four values it takes

The `needs` context carries the outcome of each direct dependency. The contexts reference defines `needs.<job_id>.result` as "The result of a job that the current job depends on. Possible values are `success`, `failure`, `cancelled`, or `skipped`." Note the word direct: the same page says the context "doesn't include implicitly dependent jobs (for example, dependent jobs of a dependent job)", so a job two levels down has to name the job it wants to read.

That gives you a precise instrument. `always()` runs the job whatever happened, including a cancelled run, which is why the docs warn against it. `!cancelled()` runs it unless somebody pressed cancel. Either overrides the default success check, and a comparison on `needs.<job>.result` says which case you want.

```.github/workflows/release.yml, corrected (illustrative)
deploy:
    needs: build
    if: ${{ !cancelled() && needs.build.result != 'failure' }}
    runs-on: ubuntu-latest
    steps:
      - run: echo "build was ${{ needs.build.result }}"
```

## always() is the answer to a different question

The expressions reference recommends against reaching for it first: "Avoid using `always` for any task that could suffer from a critical failure, for example: getting sources, otherwise the workflow may hang until it times out. If you want to run a job or step regardless of its success or failure, use the recommended alternative: `if: ${{ !cancelled() }}`."

There is a YAML trap in that recommendation. Because `!` is reserved notation in YAML, the docs say you "must always use the `${{ }}` expression syntax or escape with `''`, `""`, or `()`" when an expression starts with it. An `if: !cancelled()` written bare is a YAML error, not an Actions one.

> If the condition itself is rejected rather than merely wrong, the message names the context: [GitHub Actions unrecognized named-value](/learn/github-actions/gha-bad-expression-context-access).

## Why there is no recorded run on this page

This is decided before any job is dispatched, so there is nothing for a script on a runner to reproduce and nothing transient to repair. The payload at the top is a real skipped job from the public cli/cli repository, read 2026-09-20, quoted to show how little the API says.

## FAQ

### Why is my GitHub Actions job skipped when nothing failed?

Because a job in its `needs` list was skipped, and a skip propagates: "If a job fails or is skipped, all jobs that need it are skipped unless the jobs use a conditional expression that causes the job to continue." The default condition on every job is a success check, so a skipped dependency disqualifies as surely as a failed one.

### always() or !cancelled() when a need is skipped?

`always()` "causes the step to always execute, and returns true, even when canceled", so a job guarded with it starts after somebody cancels the run. `!cancelled()` is true in every case except a cancellation. The expressions reference recommends the second one for jobs that should run regardless of success or failure, and warns against the first.

### How do I run a job when one of its needs was skipped?

Override the default success check and then say which results you accept, for example `!cancelled()` together with `needs.build.result != 'failure'`. actions/runner#491 is the report that made this visible: two jobs differing only by `always() &&` in front of an identical condition, one skipped and one running.

### What values can needs.<job_id>.result have?

Four, per the contexts reference: `success`, `failure`, `cancelled`, or `skipped`. The context holds direct dependencies only, so a job that wants the result of something two levels up has to name that job in its own `needs`. Dereferencing a job that is not there gives an empty string rather than an error.

## References

- [GitHub Actions: workflow syntax, jobs.<job_id>.needs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idneeds)
- [GitHub Actions: expressions, status check functions](https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#status-check-functions)
- [GitHub Actions: contexts reference, needs context](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#needs-context)
- [actions/runner#491: job-level if not evaluated when a needed job is skipped](https://github.com/actions/runner/issues/491)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
