OPEN SOURCE · MIT · SEPTEMBER 2026

Why didn't my job run? Ask GitHub's own parser.

Paste a GitHub Actions workflow, pick the event, and see which jobs run and why, down to the value of every sub-expression. The page bundles GitHub's own workflow parser and expression engine and runs them in your browser. Nothing is uploaded, there is no server and no key.

Published 2026-09-12 · updated 2026-09-18 · 95 tests · 74 real runs recorded; 757 job decisions and 60 trigger decisions replayed · about 600 KB, static

The sample with a matrix read from a job output: with ["api","web"] it makes four test jobs; with [] the test card turns NO JOB CREATED and the job that needs it is skipped, because GitHub reports an empty matrix as failure; marking test (api, 20) as failing makes fail-fast cancel the other three with GitHub's own annotation
Change the event, the branch, the labels, a job's result or a job output, and every job turns green or grey with the reason next to it. Here a matrix read from a job output comes out empty: GitHub creates no job for it and reports it as failed.

The problem

Every GitHub Actions user has stared at a grey "skipped" job and asked why. The usual way to find out is a dozen "test ci" commits, each one a minute of waiting, each one changing one thing in an if: line until the job goes green. The expression language has coercion rules that are easy to get wrong, the on: filters have their own pattern syntax, and a skipped job silently skips everything that needs it.

This page answers in a second. Paste the workflow, choose push to main or a pull_request from a fork with a release label or a workflow_dispatch with deploy: true, and read the answer.

if: github.event.pull_request.head.repo.fork == false
→ SKIPPED on pull_request from a fork:
  github.event.pull_request.head.repo.fork is true → ... == false is false
→ RUNS on push (probably not what you meant):
  github.event.pull_request.head.repo.fork is null → null == false is true
  (loose equality: null and boolean are compared as numbers, null/false/'' count as 0)

What it does

On the command line

On the page you tell the simulator which files changed. Inside your repository git already knows, so the command line version reads the workflows, the branch, the commit and the changed files from the repository you run it in, and prints what the page would show for every workflow and every job. It needs Node 22 or later and git.

cd your-repository
npx github:barbarkaragul-oss/why-didnt-my-job-run                        # a push of the last commit on this branch
npx github:barbarkaragul-oss/why-didnt-my-job-run --event pull_request   # this branch as a pull request into the default branch

What comes from git: the workflows from .github/workflows in the working tree, uncommitted edits included, so a change can be tried before it is pushed. For a push, the changed files are git diff --name-only <base>..<head>, the base defaulting to the head's first parent (a push of one commit; --base origin/main for a push of several). For a pull request, they are the diff from the merge base of the base branch and the head, which is the diff GitHub matches paths against. The repository comes from the origin remote and the default branch from origin/HEAD. Everything else takes the page's defaults, and flags such as --label, --input, --output and --fail set what git cannot know. Each job is printed as runs, skipped, fails, cancelled, blocked, rejected or not-created, a matrix job with one line per job GitHub creates, and --json prints all of it for scripts.

--expect-run turns a question into an exit code: 1 unless the named job (or matrix combination) runs. As a check in CI, for example to make sure a change to the workflow keeps deploy running on pushes to main:

- uses: actions/checkout@v7
  with:
    fetch-depth: 0   # the diff needs the commit before the head
- run: npx --yes github:barbarkaragul-oss/why-didnt-my-job-run --branch main --expect-run deploy

npx downloads the repository and runs dist/cli.js, one committed file with GitHub's parser packages bundled in; nothing else is installed and nothing is built on your machine. The package is not on the npm registry; to pin a version, add a commit: npx github:barbarkaragul-oss/why-didnt-my-job-run#<sha>.

How it works

GitHub publishes the pieces of its Actions language server as MIT-licensed npm packages, the same ones its VS Code extension uses to validate workflows:

Both are pure JavaScript with no Node dependencies, so the page bundles them and runs them in the browser. What is written for this project is only what the packages do not cover:

PieceSource of truth
on: filter matchingthe docs' filter cheat sheet, every example of which is a test
github context per eventthe docs' events table, plus real contexts recorded from runs
status functions from every ancestorreal runs: two chain workflows and a step workflow
sub-expression tracea subclass of GitHub's evaluator that records every node
context availabilitythe docs' context-availability table, confirmed by a rejected workflow in the fixtures repository
matrix expansiona port of GitHub's own C# matrix builder, published under src/Sdk/WorkflowParser in the MIT-licensed actions/runner (MatrixBuilder.cs, IdBuilder.cs, JobNameBuilder.cs); the ${{ }} values and the include/exclude comparisons run through @actions/expressions; checked against a recorded run

The example payloads come from GitHub's @octokit/webhooks-examples; the scenario knobs patch them.

Verified against real runs

Documentation can be read two ways; a run cannot. The fixtures repository, barbarkaragul-oss/wdmjr-fixtures (public, every run visible under its Actions tab), ran a workflow with 31 deliberately tricky jobs (32 rows as GitHub lists them, since the matrix job has two legs: fork checks, label checks, null == false, inputs.deploy == 'true', needs chains with a failing build, always(), !cancelled(), needs.x.result == 'skipped', github.run_attempt == 1, case-insensitive ref_name == 'MAIN', a job-level hashFiles()) on real pushes to branches and tags, pull requests (opened, labeled, draft), two manual dispatches, a release and a labelled issue. Each run dumped its own toJSON(github); the API reported each job's conclusion. Two more workflows chained jobs three deep behind a failing job and behind a job skipped by its own condition, one tested step-level conditions, and one more, dispatched by hand, pinned down matrices. The simulator's repository holds 74 recordings (22 of the 31-job workflow with its full github context, the chain and step workflows, six filter workflows, 16 failed runs of rejected files, and the matrix workflow), and the replay tests require the simulator to reproduce 757 job run-or-skip decisions and 60 trigger decisions, plus every job of the matrix run under its full name. npm test runs them.

Four things the runs pinned down. Status functions look at every ancestor, not just needs: success() is false, and a job with no if: is skipped, when any job upstream was skipped or failed, even three hops away and even when the direct need succeeded, so always() rescues only the job it is on; failure() is true when any ancestor failed, even when the direct need was skipped. Step-level success() and failure() look at the job's own steps, not at needs: in an always() job after a failed need, a step with if: failure() is skipped and one with if: success() runs. The push payload GitHub hands to Actions has no file lists: the docs say so in a note under the push event, and the recordings show the exact keys of head_commit and commits[], so changed files cannot come from the event, in this tool or in any other: on the page you type them, and the command line version asks git. And a job-level hashFiles() is a validation error that fails the whole file with zero jobs, sibling jobs included, rather than skipping just that job.

When the matrix comes out empty

A common way to run one job per changed package is to let an earlier job write the list and build the matrix from it with fromJSON(). The matrix workflow in the fixtures repository recorded what GitHub does when that list comes out empty, as it does when nothing changed. The workflow, trimmed to the lines that matter:

plan:
  outputs:
    whole_empty: ${{ steps.p.outputs.whole_empty }}
  steps:
    - id: p
      run: echo 'whole_empty={"include":[]}' >> "$GITHUB_OUTPUT"
whole_json_empty:
  needs: plan
  strategy:
    matrix: ${{ fromJSON(needs.plan.outputs.whole_empty) }}
after_whole_json_empty:
  needs: whole_json_empty
after_whole_json_empty_always:
  needs: whole_json_empty
  if: always()
JobWhat the run shows
plansuccess
the matrix jobno job at all: not failed, not skipped, not listed by the API, no annotation
a job that needs itskipped
a job that needs it, with always()success; it sees needs.<id>.result == 'failure'

A single key read from an empty list (target: ${{ fromJSON(needs.plan.outputs.none) }} with none=[]) ended the same way. So a run can finish with a job that is simply not there, and the only trace is downstream: the jobs that need it see failure and, unless they use always(), are skipped. The simulator keeps a matrix read from a job output unknown until you type that output; type an empty one and the card says what GitHub does and does not tell you, and the command line version prints the job as not-created.

The same run pinned down the rest of matrix expansion. An include entry is added to every combination whose original values it does not contradict, and an entry that fits none becomes a job of its own. The job name lists the values of the original keys only, in the order they are declared (include_exclude (apple, cat), although that job also has a color and a shape). With fail-fast, the default, one failing combination cancelled the others with the annotation The strategy configuration was canceled because "failfast._2" failed.; without it they finished; either way the jobs that needed the matrix saw failure. And a matrix job whose own if: is false is one skipped job under its plain name, not one per combination.

Limits

Wrong result? Open an issue with the workflow and the event. If a real run disagrees with the simulator, that run becomes a fixture.

How it was built

The first version was written in one day with Claude Fable 5.1 doing the typing and a human deciding what to build, what to verify and what to ship. The reason that is acceptable for a tool people will trust with their CI is the ground-truth harness: the fixtures repository, the recorded contexts and the replay tests are what make the answer right, not the model. When the simulator and GitHub disagree, GitHub wins and the run becomes a test.

Run it locally

git clone https://github.com/barbarkaragul-oss/why-didnt-my-job-run && cd why-didnt-my-job-run
npm install
npm test          # docs-derived filter tests + ground-truth replay + the CLI against temporary git repositories
npm run bundle    # docs/ (the static site) and dist/cli.js (the command line version); both are committed
npx tsx src/cli.ts --help   # the command line version straight from the source

Türkçe özet

"Why didn't my job run?", GitHub Actions'ta bir job'un neden çalışmadığını (ya da neden çalıştığını) saniyeler içinde gösteren açık kaynak bir simülatör. Workflow YAML'ını yapıştırıyorsun, olayı seçiyorsun (main'e push, fork'tan gelen pull request, etiket eklenmesi, elle tetikleme), her job yeşil ya da gri oluyor ve yanında sebebi yazıyor: hangi karşılaştırma belirleyici oldu, iki tarafın değeri neydi, hangi tür dönüşümü devreye girdi. Numarası şu: GitHub'ın kendi MIT lisanslı paketleri (workflow ayrıştırıcısı ve ifade motoru) doğrudan tarayıcıda çalışıyor, yani cevap GitHub'ın vereceği cevapla aynı. Üstüne herkese açık bir test reposunda (wdmjr-fixtures) 31 job'lık tuzak dolu bir workflow gerçek push, tag, pull request, dispatch ve release olaylarıyla koşturuldu, zincir, adım koşulları, filtreler ve matrix için başka workflow'lar eklendi; 74 gerçek koşu kaydedildi ve simülatörün 757 job kararını ve 60 tetikleme kararını birebir tekrar etmesi test ediliyor. Koşuların ortaya çıkardığı kural: success() ve failure() sadece doğrudan needs'e değil bütün ata job'lara bakıyor, always() yalnız kendi job'unu kurtarıyor. Sayfada sunucu yok, anahtar yok, hiçbir şey yüklenmiyor. Matrix artık GitHub'ın kendi runner'ındaki (actions/runner) matrix kurucusunun TypeScript'e aktarılmış haliyle genişletiliyor: include/exclude, job adları, fail-fast ve matrix okuyan adım koşulları her kombinasyon için ayrı değerlendiriliyor. Gerçek bir koşunun gösterdiği: bir job çıktısından fromJSON ile kurulan matrix boş gelirse GitHub hiç job oluşturmuyor (ne başarısız, ne atlanmış, listede yok, hiçbir açıklama yok), ona bağlı job'lar needs.<id>.result == 'failure' görüyor ve always() kullanmıyorlarsa atlanıyor. Bir de komut satırı sürümü var: npx github:barbarkaragul-oss/why-didnt-my-job-run --event push workflow'ları, dalı, commit'i ve değişen dosyaları git'ten okuyor; --expect-run ile CI'da kontrol olarak çalışıyor. Sayfada değişen dosyaları hâlâ kendin yazıyorsun, çünkü GitHub push payload'ından dosya listesini siliyor. Lisans MIT.