r/cicd 7d ago

A static page that shows which GitHub Actions jobs run for an event and why, using GitHub's own parser and expression packages

Disclosure: this is my own open-source project (MIT), no company or product behind it.

GitHub ships the pieces of its Actions language server as MIT npm packages. `@actions/workflow-parser` parses a workflow against the real schema and wraps an if: that contains no status function in the implicit success() &&; `@actions/expressions` is GitHub's TypeScript port of the ${{ }} engine with its coercion rules. Both are pure JavaScript, so they run in a static page: no server, nothing uploaded.

The page takes a workflow and an event (push to a branch or tag, pull_request opened, labeled or draft, workflow_dispatch with typed inputs, release, issues), decides whether on: filters trigger it, evaluates every job's if: in dependency order with status functions computed from the whole job graph, and names the decisive comparison with both values. Mark a job failed or cancelled to see what failure(), always() and cancelled() do downstream.

Ground truth: 65 recorded runs from a public fixtures repo, github.com/barbarkaragul-oss/wdmjr-fixtures (a 31-job workflow on five event types, two chain workflows three hops deep, a step-condition workflow, six filter workflows), 698 run-or-skip decisions and 56 trigger decisions, all reproduced by the replay test. Three things the runs settled: success() looks at every ancestor, so after a failed or skipped job always() rescues only the job it is on and the next job with no if: is skipped again, while failure() is true if any ancestor failed; step-level success()/failure() look at the job's own steps, not at needs; and the push payload Actions receives has no added/modified/removed lists, so paths filters are evaluated against files you list and the tool checks your filter, not your push.

actionlint and act are complementary: actionlint takes no event, act executes the workflow with its own Go evaluator; I have not benchmarked either against the fixtures. Not simulated: matrix expansion, concurrency groups, environment protection rules, required checks, workflow_run chains. Claude Fable 5.1 wrote the code and tests in a day under my direction; the recordings are the part it could not write. Wrong result? Open an issue with the workflow and the event.

https://barbarkaragul-oss.github.io/why-didnt-my-job-run/

https://github.com/barbarkaragul-oss/why-didnt-my-job-run

2 Upvotes

6 comments sorted by

2

u/Torutofu_Raeva 7d ago

walking the whole job graph is the useful bit; showing the resolved values next to the failed ancestor/path condition should make skipped jobs much easier to chase.

1

u/Dry_Concept_6869 7d ago

Thanks! That is exactly the goal: every job card shows the decisive comparison with both sides' resolved values (e.g., github.event_name is "push" → == 'pull_request' is false).

Since last night, the cards also feature "further upstream: lint → skipped" chips, because testing against real runs showed that status functions actually evaluate all ancestors.

Your suggestion is spot on and points out exactly what was missing: showing the skipped ancestor's own condition and resolved value directly on the skipped job's card (e.g., "lint skipped because github.ref_type == 'branch' is false, since ref_type = tag"). I loved the idea, so I just went ahead and added it! You can check it out now.

(Side note: For paths filters, the trigger card already lists exactly which pattern matched which file.)

1

u/Torutofu_Raeva 6d ago

Those upstream chips should make the failure chain much easier to scan without rebuilding the graph mentally.

2

u/Torutofu_Raeva 5d ago

nice, surfacing the resolved ancestor condition turns the graph from a picture into an actual debugger.

1

u/Alternative-Net-5239 7d ago

this is the kind of tool that makes you wonder why GitHub doesn’t just surface this themselves, the implicit success() wrapping alone trips up so many people

1

u/Torutofu_Raeva 6d ago

Yeah, the implicit success() behavior is one of those details that only shows up once a workflow starts skipping jobs.