Skip to content

ci: parse-check the shell in workflows, the way builder now does - #2290

Merged
widgetii merged 3 commits into
masterfrom
workflow-shell-lint
Aug 19, 2026
Merged

ci: parse-check the shell in workflows, the way builder now does#2290
widgetii merged 3 commits into
masterfrom
workflow-shell-lint

Conversation

@widgetii

Copy link
Copy Markdown
Member

Port of OpenIPC/builder#122 and #123.

Why

The publish job in build.yml carries if: github.event_name != 'pull_request' — there are no artifacts to publish on a PR, so the whole path is unreachable from PR CI by construction. Every edit to it ships unexecuted, and the first thing to run it is the 22:30 cron.

That isn't hypothetical. OpenIPC/builder runs the same publish job, ported from this one, and OpenIPC/builder#121 edited its Collect step and shipped an if with no closing fi. Both PR runs were green without ever reaching the step. The nightly then built all 107 devices, staged 219 assets, and died in bash -e with syntax error: unexpected end of file before writing a single release. Nothing published that night.

The guard that broke there is the one this repo backported in #2279. The same typo is one edit away here, in a job whose failures surface at 22:30 to nobody.

What

  • .github/scripts/lint-workflow-shell.py — parses every run: block in .github/workflows/ with bash -n
  • .github/workflows/lint.yml — runs it on PRs and on master pushes touching workflows or the linter
  • ci-matrix.py — classifies both as no-build

This lands green

All 43 run blocks already in this repo parse clean. This is a guard against the next edit, not a fix for a current break.

To confirm it earns its keep, I removed the fi from this repo's own Collect guard in a scratch copy:

FAIL /tmp/fwcheck/build.yml:376 (Collect assets)
       <run block>: line 24: syntax error: unexpected end of file from `if' command on line 13

— the same message the runner gives, exit 1.

Notes on the checker

  • ${{ }} is not shell, so each expression is replaced with a plain word first. Real limitation: an expression interpolating shell syntax is checked as the word, not as what it expands to. The substitution preserves line counts so reported lines still point at the right line.
  • --self-test runs first in CI. This class of checker breaks by making everything pass, which is indistinguishable from a clean tree, so it asserts an unterminated if containing an expression is still rejected while the closed form passes.
  • Steps are found structurally — any mapping with a run: key — plus a discovery floor, so a broken walk fails instead of reporting green.
  • Syntax only. Says nothing about quoting or -e semantics. actionlint would cover more and is worth considering separately.

Two deliberate choices

Its own file, not a fourth job in shell-tests.yml. That file has no push trigger by design; sharing one would start running the busybox and sysupgrade jobs on every master push too. The push trigger matters here because its absence is how builder#121 reached master broken — PR coverage only holds if master hasn't moved since the last PR run.

PyYAML is imported first and installed only on failure. An unconditional apt-get update in this step sat for six minutes on builder's first master push (builder#123), on a job whose whole argument for existing is that it answers in seconds. if rather than cmd && exit 0, because under bash -e the latter fails the step on exactly the branch where the install needs to run.

Cost

This PR takes the full 99-board matrix because it edits ci-matrix.py, which always widens — the selector isn't trusted to pick a smaller matrix for its own changes. Future changes to the linter or lint.yml select 0 boards. Landing the classification separately isn't possible: the selector's self-test asserts classified files exist.

🤖 Generated with Claude Code

The publish job in build.yml carries
`if: github.event_name != 'pull_request'` -- there are no artifacts to
publish on a PR, so the whole path is unreachable from PR CI by
construction. Every edit to it ships unexecuted, and the first thing to
run it is the 22:30 cron.

That is not hypothetical. OpenIPC/builder runs the same publish job,
ported from this one, and OpenIPC/builder#121 edited its Collect step
and shipped an `if` with no closing `fi`. Both PR runs were green
without ever reaching the step. The nightly then built all 107 devices,
staged 219 assets, and died in `bash -e` with "syntax error: unexpected
end of file" before writing a single release. Nothing published that
night.

The guard that broke there is the one this repo backported in #2279, so
the same typo is one edit away here, in a job whose failures surface at
22:30 to nobody. lint-workflow-shell.py parses every `run:` block in
.github/workflows/ with `bash -n`; lint.yml runs it on PRs and on master
pushes that touch either. Ported from OpenIPC/builder#122 and #123.

The 43 run blocks already in this repo all parse clean, so this lands
green -- it is a guard against the next edit, not a fix for a current
break. Confirmed it would earn its keep by removing the `fi` from this
repo's own Collect guard in a scratch copy: flagged at
build.yml:376 (Collect assets), with the same message the runner gives.

Notes on the checker:

- ${{ }} is not shell, so each expression is replaced with a plain word
  first. That is a real limitation -- an expression interpolating shell
  syntax is checked as the word, not as what it expands to -- and the
  substitution preserves line counts so reported lines still point at
  the right line. --self-test asserts an unterminated `if` containing an
  expression is still rejected while the closed form passes, because the
  way this breaks is by making everything pass.

- Steps are found by walking for any mapping with a `run:` key rather
  than by the jobs.*.steps[*] path, and a discovery floor fails the run
  if the walk stops finding them. A checker that silently checks nothing
  looks exactly like a clean tree.

- Syntax only. It says nothing about quoting or `-e` semantics.

Its own file rather than a fourth job in shell-tests.yml, which
deliberately has no push trigger: sharing one would start running the
busybox and sysupgrade jobs on every master push too. PyYAML is imported
first and installed only if that fails, because an unconditional
`apt-get update` in this step sat for six minutes on builder's first
master push.

ci-matrix.py classifies both new files as no-build; unknown widens, and
they cannot change a byte of what reaches a camera. This PR still takes
the full matrix because it edits ci-matrix.py itself, which always
widens -- the selector is not trusted to pick a smaller matrix for its
own changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add CI syntax checks for workflow shell blocks

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 🕐 20-40 Minutes

Grey Divider

AI Description

• Parse every workflow run: block with its declared Bash or POSIX shell.
• Self-test expression handling and discovery to prevent silently ineffective checks.
• Lint pull requests and relevant master pushes without building firmware.
Diagram

graph TD
  A["Workflow change"] --> B["Lint workflow"] --> C["Self-test"] --> D["YAML discovery"] --> E["Expression substitution"] --> F{"Known shell?"}
  F -->|bash or sh| G["Syntax parser"] --> H["CI result"]
  F -->|other shell| H
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Adopt actionlint
  • ➕ Validates broader GitHub Actions structure and expressions
  • ➕ Uses a maintained, ecosystem-standard workflow linter
  • ➕ Can integrate shellcheck for deeper shell diagnostics
  • ➖ Introduces another downloaded or packaged tool and version policy
  • ➖ Broader validation may require repository-specific suppressions
  • ➖ Expands scope beyond the targeted shell parse regression
2. Extend shell-tests.yml
  • ➕ Keeps shell-related checks in one workflow
  • ➕ Avoids adding another workflow file
  • ➖ Adding its required push trigger could launch unrelated expensive jobs
  • ➖ Job-level event conditions would make existing workflow behavior more complex
  • ➖ Weakens the isolation and fast feedback intended by this check

Recommendation: Keep the focused checker in its own workflow: it directly closes the unreachable-job syntax gap, runs quickly on both PRs and relevant master pushes, and avoids changing existing shell-test scheduling. Consider actionlint separately as a broader follow-up rather than expanding this targeted safeguard.

Files changed (3) +390 / -4

Enhancement (1) +315 / -0
lint-workflow-shell.pyAdd structural shell syntax checker for workflow run blocks +315/-0

Add structural shell syntax checker for workflow run blocks

• Adds a PyYAML-based workflow walker that discovers 'run:' blocks, inherits shell defaults, preserves line numbers while replacing Actions expressions, and invokes 'bash -n' or 'sh -n'. Includes diagnostics, a discovery floor, unsupported-shell reporting, temporary-file cleanup, and self-tests for malformed syntax and substitution behavior.

.github/scripts/lint-workflow-shell.py

Other (2) +75 / -4
ci-matrix.pyExclude workflow linting files from firmware builds +7/-4

Exclude workflow linting files from firmware builds

• Classifies the new lint workflow and checker as no-build changes. Adds selector self-test cases confirming that either file produces a zero-board matrix.

.github/scripts/ci-matrix.py

lint.ymlRun workflow shell parsing in dedicated CI +68/-0

Run workflow shell parsing in dedicated CI

• Adds a read-only lint job for pull requests, relevant master pushes, and manual dispatches. It conditionally installs PyYAML, validates the checker itself, and then parse-checks every workflow run block under a bounded timeout.

.github/workflows/lint.yml

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 19, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. lint-workflow-shell.py lacks build wiring 📘 Rule violation ≡ Correctness
Description
The PR adds a Python source file without adding a package Config.in, .mk build/install rule, or
defconfig selection. Under Rule 2's literal failure criteria, the source is not wired into the
firmware build.
Code

.github/scripts/lint-workflow-shell.py[R1-2]

+#!/usr/bin/env python3
+"""Parse-check every `run:` block in .github/workflows/.
Evidence
Rule 2 explicitly identifies newly added .py or script files without Config.in, .mk, and
defconfig wiring as failures. The cited branch regions show the new Python source and that it is
invoked directly as CI tooling rather than through a firmware package.

Rule 2: New sources are wired into the build
.github/scripts/lint-workflow-shell.py[1-2]
.github/workflows/lint.yml[65-68]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The newly added Python source has no package `Config.in`, `.mk` build/install rule, or defconfig selection as required by PR Compliance ID 2.
## Issue Context
The workflow invokes the script as repository-hosted CI tooling, but Rule 2's literal criteria require added `.py` or script sources to be represented in the firmware package build and selected by at least one defconfig.
## Fix Focus Areas
- .github/scripts/lint-workflow-shell.py[1-2]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Non-step run keys linted ✓ Resolved 🐞 Bug ≡ Correctness
Description
walk() treats every scalar run key anywhere in the YAML document as a shell step, including
arbitrary keys under env, action with, or matrix data. A valid non-shell value named run can
therefore be passed to Bash and incorrectly fail CI.
Code

.github/scripts/lint-workflow-shell.py[R129-134]

+        run = keys.get("run")
+        if isinstance(run, yaml.ScalarNode):
+            step_shell = shell
+            sh = keys.get("shell")
+            if isinstance(sh, yaml.ScalarNode):
+                step_shell = sh.value
Evidence
The walker recursively enters every mapping and sequence, then classifies a mapping solely by the
presence of a scalar run key. Existing workflows demonstrate that the traversed tree includes
non-step mappings such as job-level env and strategy.matrix, so the classifier has no structural
protection against a non-step field named run.

.github/scripts/lint-workflow-shell.py[102-151]
.github/workflows/build.yml[125-144]
.github/workflows/gcc-compat.yml[25-38]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The workflow walker identifies any mapping containing a scalar `run` key as a shell step. GitHub workflow documents contain many unrelated mappings, so arbitrary environment, action-input, or matrix fields named `run` can be incorrectly parsed as shell.
## Issue Context
The recursive walk visits every mapping in the document, while current workflows already contain nested `env`, `with`, and matrix mappings. Discovery should follow the workflow schema and collect only entries from job `steps` lists that are actual run steps.
## Fix Focus Areas
- .github/scripts/lint-workflow-shell.py[102-151]
- .github/workflows/build.yml[125-144]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Expression terminator parsed naively ✓ Resolved 🐞 Bug ≡ Correctness
Description
The non-greedy expression regex stops at the first }}, even when those characters occur inside a
quoted GitHub expression string. It then sends the leftover expression text to Bash, which can
reject an otherwise valid workflow block.
Code

.github/scripts/lint-workflow-shell.py[79]

+EXPR = re.compile(r"\$\{\{.*?\}\}", re.DOTALL)
Evidence
The implementation promises to replace every complete ${{ ... }} expression, but .*?\}\}
necessarily ends at the first closing pair regardless of whether it is part of expression string
content. The current self-tests cover ordinary and multiline expressions but do not exercise an
embedded terminator.

.github/scripts/lint-workflow-shell.py[79-99]
.github/scripts/lint-workflow-shell.py[241-285]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
GitHub expressions are replaced using a non-greedy regular expression that terminates at the first `}}`. A quoted string within an expression may contain those characters, causing only part of the expression to be replaced and producing false shell syntax failures.
## Issue Context
Replace expressions with a small scanner that recognizes expression boundaries while respecting quoted strings, and add a self-test containing `}}` inside an expression string. Preserve the existing line-count behavior.
## Fix Focus Areas
- .github/scripts/lint-workflow-shell.py[79-99]
- .github/scripts/lint-workflow-shell.py[241-285]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread .github/scripts/lint-workflow-shell.py
Comment thread .github/scripts/lint-workflow-shell.py
Comment thread .github/scripts/lint-workflow-shell.py Outdated
widgetii and others added 2 commits August 19, 2026 12:33
Two Qodo findings on #2290, both real, both confirmed against the
shipped implementation rather than taken on trust.

walk() collected any mapping with a scalar `run` key, anywhere in the
document. That is one key name away from linting things that are not
shell: an action input, an env var or a matrix field called `run` holds
arbitrary text, and feeding it to bash -n fails a workflow that is fine.
On the fixture below the old walk collects three blocks where there is
one real step. Steps now have to come out of a `steps:` sequence, which
is the actual schema invariant and still not a hardcoded jobs.*.steps[*]
path, so composite actions (`runs: steps:`) keep working.

The expression substitution used `\$\{\{.*?\}\}`, which stops at the
first `}}` even when it is inside a string literal. On

    x=${{ fromJSON('{"a": {"b": 1}}') }}

the old code produces `x=__GHA_EXPR__') }}` and bash -n then reports an
unbalanced quote -- a false failure on a valid block, in the direction
that trains people to ignore the job. Replaced with a scanner that
tracks GitHub's single-quoted strings, including the doubled '' escape,
and returns the text untouched if an expression is never closed.

Both are latent here: nothing in this repo currently has a `run` key
outside a step or a `}}` inside an expression string, and the block
count is unchanged at 46. They are fixed because the failure mode is a
red job with nothing wrong with the tree.

Self-tests for both, and they do fail against the old implementation --
checked by importing builder's copy, which still carries it. That copy
needs the same fix.

Not addressed: the third finding says a .py under .github/scripts/ needs
a package Config.in, .mk rule and a defconfig selecting it. That rule is
about firmware sources; this is CI tooling that never reaches an image,
and ci-matrix.py and enrich_manifest.py sit in the same directory under
the same terms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The DISCOVERY and EXPRESSIONS paragraphs still described the behaviour
ef9a9c1 replaced -- "any mapping anywhere in the document that has a
run: key", and a substitution with no mention of why it is scanned
rather than matched. A docstring that describes the previous version is
worse than none, since it is the thing the next person reads before
deciding what is safe to change.

Also drops the now-unused `re` import.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
widgetii added a commit to OpenIPC/builder that referenced this pull request Aug 19, 2026
Two bugs in the linter #122 added, found by Qodo reviewing the port of
it to OpenIPC/firmware#2290 and confirmed against this copy rather than
taken on trust.

walk() collected any mapping with a scalar `run` key, anywhere in the
document. That is one key name away from linting things that are not
shell: an action input, an env var or a matrix field called `run` holds
arbitrary text, and feeding it to bash -n fails a workflow that is fine.
On a fixture with one real step plus an `env: run:` and a `with: run:`,
this copy collects three blocks. Steps now have to come out of a
`steps:` sequence, which is the actual schema invariant and still not a
hardcoded jobs.*.steps[*] path, so composite actions (`runs: steps:`)
keep working.

The expression substitution used `\$\{\{.*?\}\}`, which stops at the
first `}}` even when it is inside a string literal. On

    x=${{ fromJSON('{"a": {"b": 1}}') }}

this copy produces `x=__GHA_EXPR__') }}` and bash -n then reports an
unbalanced quote -- a false failure on a valid block, in the direction
that trains people to ignore the job. Replaced with a scanner that
tracks GitHub's single-quoted strings, including the doubled '' escape,
and returns the text untouched if an expression is never closed.

Both are latent here: nothing in this repo has a `run` key outside a
step or a `}}` inside an expression string, and the block count is
unchanged at 23. They are fixed because the failure mode is a red job
with nothing wrong with the tree, which is how a check stops being
believed.

Self-tests for both. The docstring described the behaviour this
replaces, so it is corrected too, and the now-unused `re` import drops.
Keeps this copy identical to firmware's apart from the incident
paragraph and the block floor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@widgetii
widgetii merged commit bf0a813 into master Aug 19, 2026
108 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant