Skip to content

Nothing runs actionlint — a structural workflow defect lands as a job-less red on a consuming repo #3228

Closed

Description

@rbuergi

The gap

CI validates workflows in several ways — check-workflow-timeouts.py caps every job, the shared-rule
check, the pin guards — but nothing runs actionlint, and no check catches a structural defect
in a workflow file.

What it cost, measured today

#3225 inserted an input between the previous input's required: line and its default: line. The
orphaned default attached to the new input, producing a duplicate default key, and the input
above silently lost its default. GitHub refuses the whole workflow.

The failure surfaced in a different repository: MeshWeaver.Plugins#1268 run 33777768374
concluded failure with zero jobs and zero contexts. A startup_failure renders as an ordinary
red and names nothing — no job to open, no log to read, no annotation on the commit. Attributing it
took comparing job counts across two heads in two repos (the prior head ran 29 jobs; this one ran 0)
and then linting the lane by hand.

actionlint names it in one line, at the source:

.github/workflows/node-repo-module-pack.yml:289:9: key "default" is duplicated in input of
workflow_call event. previously defined at line:288,col:9 [syntax-check]

🚨 And the verification that "passed" could not have failed. #3225 validated the lane with
yaml.safe_load; PyYAML accepts duplicate mapping keys and silently keeps the last, so the parse
succeeded, the input's spec read back correctly, and an "unchanged required inputs" assertion passed.
Any workflow validation built on a permissive YAML load has this blind spot by construction.

Why it is worse for THIS repo than for most

These are workflow_call lanes five satellite repos pin. A structural break in one is invisible in
core — core does not call them — and lands as a job-less red on whichever satellite pins the new sha
first. The blast radius is every consumer, and the diagnosis happens furthest from the cause.

What to add

Run actionlint over .github/workflows/ in the existing workflow-validation job, failing on any
finding. Notes:

  • It is a single static binary; no runtime deps.
  • Start with -shellcheck= -pyflakes= (disable the embedded shell/python linters) so the change is
    about workflow structure only and does not arrive with a large backlog of run: findings. Those
    can be enabled later, deliberately.
  • Measured now: the repo is clean under that configuration — zero duplicate-key findings across
    every workflow after Restore always-modules' default — #3225 orphaned it onto the new input and startup-failed every caller #3226. So this arrives green rather than with a backlog to triage.
  • The node-repo lanes are the highest-value targets, but run it over all of them; the cost is
    seconds.

Filed rather than folded into #3226, which restores one line and should stay reviewable as such.

Activity

  1. rbuergi commented on Sep 4, 2026

    @rbuergi
    ContributorAuthor

    Landed on main via #3269 (merged 2026-09-04T06:08:30Z). Closing manually — the merge-queue merge did not fire the Closes link, though closingIssuesReferences resolved to this issue correctly.

    Verified on origin/main, .github/workflows/dotnet-test.yml:

    • actionlint runs over .github/workflows/, pinned to an explicit version (not latest);
    • -shellcheck= -pyflakes= as this issue asked, so it arrives about workflow STRUCTURE only, with the follow-up recorded rather than assumed;
    • it cannot pass on no evidence — the install step is || { echo "::error::could not download actionlint ${ver} from ${url} — this gate has no evidence without it, so it fails rather than skipping"; exit 1; }, with no continue-on-error: and no if: asking whether something is set. That was the part worth getting right: a skipped job and a passed job are the same colour on GitHub, which is what let Declare an image assembly SUPERSEDED so a type can move out of it into a module in one wave #3225's duplicate default key reach a satellite as a job-less red.

    The gate's own comment names the gap it closes, so the next reader does not have to reconstruct it:

    🚨 THE GAP actionlint CLOSES (MeshWeaver#3228): every check above reads a workflow with a permissive YAML load … actionlint names it in one line, at the source, on the PR that writes it

    For the record, the PyYAML blind spot this issue identified bit again the same day, in a different repo: I validated an edit to Systemorph/Memex's helm-release.yml with yaml.safe_load_all, which passed, then ran actionlint separately because of this issue. Worth considering whether the satellite repos' node-repo-validate lane should carry the same step — Memex is not a node repo and runs none of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions