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.
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
- Trigger decision. Does this event start the workflow at all? The
on:filters are applied in the documented order: activity types (and the pull_request default of opened, synchronize, reopened),branchesandbranches-ignore,tags,pathsandpaths-ignore, with GitHub's pattern syntax. Each rule reports what it matched. - Job decisions in dependency order. Every job's
if:is evaluated by GitHub's own expression package, including the implicitsuccess() &&wrapper its parser adds to a condition that has no status function, withneeds.<job>.resultand the status functions computed from every job upstream: the recorded runs showsuccess()andfailure()look at all ancestors, not only the directneeds. Mark a job as failed or cancelled to see whatfailure(),always()and!cancelled()do downstream. - Why. The decisive comparison is named with both sides' values, and a note explains the coercion:
null == falseis true,inputs.deploy == 'true'is false for a boolean input, string comparison is case-insensitive. Expand a job to see every sub-expression and its value. - Matrix expansion. A job with
strategy.matrixbecomes the jobs GitHub creates for it, each under the name the run lists (test (linux, 20)), with its ownmatrixvalues and its own step decisions: the cross product,exclude,include(added to the combinations it fits, or a combination of its own),fail-fast(mark one combination as failing and the others are cancelled with GitHub's annotation) and the result the jobs that need it see. A matrix read from a job output (fromJSON(needs.plan.outputs.list)) stays unknown until you type that output. When it comes out empty, the card says what GitHub does and does not tell you, below. - Things GitHub rejects. A job-level
hashFiles(),matrixorsecretsin a jobif:,branchestogether withbranches-ignore, an unknown key: the file is reported as GitHub would reject it and every job is marked not run, as the recorded runs show: a failed run named after the file, with no jobs, sibling jobs included. A filter list with only negative patterns is different: GitHub creates no run at all, and the trigger card says so. - The context you get. The
githubcontext for the scenario, derived per event as documented, with the GitHub Docs description of every field on hover. - Share. One link carries the workflow and the scenario, compressed into the URL fragment.
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:
@actions/workflow-parserparses the YAML against the real workflow schema and fills in the documented defaults: a job withoutif:getssuccess(), a job withif: xgetssuccess() && (x), invalid keys are errors.@actions/expressionsis the${{ }}engine: lexer, parser, evaluator, coercion rules,contains,startsWith,format,fromJSON, object filters.
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:
| Piece | Source of truth |
|---|---|
on: filter matching | the docs' filter cheat sheet, every example of which is a test |
github context per event | the docs' events table, plus real contexts recorded from runs |
| status functions from every ancestor | real runs: two chain workflows and a step workflow |
| sub-expression trace | a subclass of GitHub's evaluator that records every node |
| context availability | the docs' context-availability table, confirmed by a rejected workflow in the fixtures repository |
| matrix expansion | a 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.
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()
| Job | What the run shows |
|---|---|
plan | success |
| the matrix job | no job at all: not failed, not skipped, not listed by the API, no annotation |
| a job that needs it | skipped |
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
- On the page, changed files are typed by you, for the reason above. The command line version reads them from git.
- Matrices are expanded as GitHub's parser expands them and as the recorded run confirms, with two things left to you. Job outputs are whatever you type: a matrix that reads one stays unknown until you do, because only the job knows what it writes. And a run is not a timeline:
max-parallelis shown but changes no outcome, and when one combination fails withfail-faston, every other combination is taken to be still running and cancelled (on GitHub, those that already finished keep their result). Two cases are assumed to end like the recorded empty matrix, and the card says so: anexcludethat removes every combination, and a strategy GitHub cannot build, such as a job output used withoutfromJSON(). - Reusable workflow jobs are decided but not expanded.
- Step conditions are evaluated with the job's own step status and every previous step assumed to have succeeded (the recorded runs confirm step-level
success()/failure()look at the job's steps, not atneeds); a step whose condition readssteps.<id>.*,envorhashFiles()is marked unknown rather than guessed. A condition that readsmatrixis decided for each combination. - Not simulated: concurrency groups, required checks, environment protection rules,
workflow_runchains, timeouts.
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
- Simulator: barbarkaragul-oss.github.io/why-didnt-my-job-run
- Repository: github.com/barbarkaragul-oss/why-didnt-my-job-run (MIT)
- Sister projects: AgentMatrix and PrivacyMatrix, cited comparison matrices
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.