Skip to content

ci: close the if that cost last night's release, and parse-check the rest - #122

Merged
widgetii merged 3 commits into
masterfrom
fix-workflow-shell-parse
Aug 18, 2026
Merged

ci: close the if that cost last night's release, and parse-check the rest#122
widgetii merged 3 commits into
masterfrom
fix-workflow-shell-parse

Conversation

@widgetii

Copy link
Copy Markdown
Member

What broke

The Collect guard added to the publish job in #121 shipped without its closing fi.

Run 32096242353 — the first nightly to reach the new single publish job — built all 107 devices, staged 219 assets, and then died in bash -e:

line 25: syntax error: unexpected end of file from `if' command on line 14

Nothing was written. There is no nightly-20260818-* release; nightly and latest still carry the 17th's images (assets last touched 2026-08-17T04:19). manifest.yml ran afterwards and re-indexed yesterday's data. ci-gate went red — the gate did its job, there was simply nothing left to publish by the time it ran.

Without this, tonight's 03:00 cron fails the same way.

Why CI was green

The publish job 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. Both of #121's runs passed without ever executing the step.

#121 was explicit about this ("none of this path can run on a pull request") and checked the four guard states offline. But offline-checking the logic doesn't check that the script parses, and that's what broke. Any edit to that job ships unexecuted.

What's here

  1. The missing fi in master.yml.
  2. .github/scripts/lint-workflow-shell.py — parses every run: block in .github/workflows/ with bash -n.
  3. .github/workflows/lint.yml — runs it on PRs and on master pushes touching either file. Seconds, no runners, no matrix.

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 same limitation bash -n has with any variable. The substitution preserves line counts so reported lines still point at the right line of the workflow.
  • --self-test runs first in CI. The way this class of checker breaks is 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 — not by the jobs.*.steps[*] path, so nothing depends on guessing where they live. A discovery floor fails the run if the walk stops finding them.
  • Syntax only, deliberately. Says nothing about quoting or -e semantics. A shellcheck pass wants per-block disables for the substitution above and is a bigger, separate change.

Verification

  • Checker flags the merged master.yml with the same message the runner gave, pointing at master.yml:354 (Collect assets)
  • Passes the fixed tree — 22 run blocks across all five workflows
  • Self-test passes; exit codes correct (1 on broken, 0 on clean)
  • All five guard states re-checked against the fixed block: normal night publishes; broken download and green-but-empty dist both fail with their intended errors; dead matrix with nothing built exits clean; partial matrix proceeds

Related

OpenIPC/firmware's backport (#2279) has the fi and is unaffected — its nightly ran clean at 22:46 UTC: 397 assets collected, paced uploads, dated release with 109 images + 96 sizes sidecars, both alias tags refreshed and moved to the built commit. Firmware has no equivalent workflow-shell lint either, so the same gap exists there; worth porting separately.

🤖 Generated with Claude Code

…rest

The Collect guard added to the publish job in #121 shipped without its
closing `fi`. Run 32096242353 -- the first nightly to reach the new
single publish job -- built all 107 devices, staged 219 assets, and then
died in `bash -e` with

    line 25: syntax error: unexpected end of file from `if' command on line 14

before writing anything. No nightly-20260818-* release exists, `nightly`
and `latest` still carry the 17th's images, and ci-gate went red. The
gate did its job; there was simply nothing left to publish by then.

Nothing caught it earlier because the publish job carries
`if: github.event_name != 'pull_request'` -- there are no artifacts to
publish on a PR, so the entire path is unreachable from PR CI by
construction. Both of #121's runs were green without ever executing the
step. #121 said as much ("none of this path can run on a pull request")
and checked the four states offline, but offline-checking the logic does
not check that the script parses, and that is what broke.

So the one-line fix, plus the check that would have caught it in seconds
without a runner: lint-workflow-shell.py parses every `run:` block in
.github/workflows/ with `bash -n`, and lint.yml runs it on PRs and on
master pushes that touch either.

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, so nothing depends on guessing where
  they live, 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; a
  shellcheck pass wants per-block disables for the substitution above
  and is a separate change.

Verified: the checker flags the merged master.yml with the same message
the runner gave, passes the fixed tree (22 blocks), and all five guard
states behave as #121 intended -- normal night publishes, a broken
download and a green-but-empty dist both fail, and a dead matrix with
nothing built still exits clean.

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

Copy link
Copy Markdown

PR Summary by Qodo

Fix broken publish guard and add CI lint for workflow run: shell syntax

🐞 Bug fix ✨ Enhancement ⚙️ Configuration changes 🕐 20-40 Minutes

Grey Divider

AI Description

• Close the missing fi in the publish-job asset collection guard.
• Add a Python linter that bash -n parse-checks every workflow run: block.
• Run the linter in CI on PRs and on master pushes touching workflows or the linter.
Diagram

graph TD
  A["PR / master push"] --> B["lint.yml"] --> C["lint-workflow-shell.py"] --> E{{"bash -n"}} --> F["Fail fast in CI"]
  C --> D["workflows (*.yml)"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Adopt actionlint (with shellcheck integration)
  • ➕ Broader workflow validation (YAML schema, expressions, job wiring) beyond shell parsing
  • ➕ Maintained upstream; less custom code to own
  • ➕ Can optionally lint run: blocks with shellcheck
  • ➖ Heavier dependency/tooling; may require tuning for ${{ }} substitutions and repo-specific patterns
  • ➖ Broader lint surface can introduce noise vs the targeted bash -n goal
2. Execute publish path on PRs using a dummy artifact/release dry-run
  • ➕ Catches runtime errors, not just parse errors
  • ➕ Validates end-to-end job logic in the same environment
  • ➖ Significantly more CI time/complexity; may require credentials or careful no-op publishing
  • ➖ Harder to keep safe (avoiding real release writes)
3. Minimal inline guard: `bash -n` on extracted run blocks via a shell script
  • ➕ No Python/YAML dependency; very small footprint
  • ➖ Reliable YAML traversal and line mapping is harder in pure shell
  • ➖ More fragile parsing/discovery; easier to regress into “checked nothing”

Recommendation: The PR’s approach (targeted bash -n parse checks + discovery floor + self-test) is the best fit for the immediate failure mode: a release-only run: block that never executes on PRs. Consider migrating to actionlint later if you want broader workflow correctness checks, but keeping this focused reduces noise while still preventing a repeat of the missing-fi outage.

Files changed (3) +348 / -0

Enhancement (1) +306 / -0
lint-workflow-shell.pyAdd workflow 'run:' block shell parse linter with self-test +306/-0

Add workflow 'run:' block shell parse linter with self-test

• Introduces a Python tool that structurally walks workflow YAML to find any mapping with a 'run:' key, inherits 'defaults.run.shell', substitutes '${{ ... }}' while preserving line counts, and validates syntax via 'bash -n'/'sh -n'. Includes a discovery floor to avoid false-green runs and a '--self-test' to ensure substitution doesn’t neuter the checker.

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

Bug fix (1) +1 / -0
master.ymlFix unterminated 'if' in Collect assets publish guard +1/-0

Fix unterminated 'if' in Collect assets publish guard

• Adds the missing 'fi' to close the Collect-assets guard so the publish job’s bash script parses and the nightly release path can run to completion.

.github/workflows/master.yml

Other (1) +41 / -0
lint.ymlAdd CI workflow to run shell parse lint on PRs and relevant pushes +41/-0

Add CI workflow to run shell parse lint on PRs and relevant pushes

• Creates a new 'lint' workflow that runs the linter self-test first, then scans all workflows. Triggers on PRs to master, workflow_dispatch, and master pushes that modify workflows or the linter script, with a repository/visibility guard to avoid unintended cost on private mirrors.

.github/workflows/lint.yml

The new lint.yml and lint-workflow-shell.py were names ci-matrix.py had
never heard of, and unknown widens by design -- so the first push of the
previous commit queued all 107 devices to prove a one-line YAML fix.
#121, which touched only master.yml, ran 19.

Neither file can change a byte of what a build produces, which is
exactly what NO_BUILD_WORKFLOWS and NO_BUILD_SCRIPTS are for. Self-test
cases added for both, alongside the manifest pair they mirror.

This PR still takes the full matrix, because it now edits ci-matrix.py
itself and that always widens -- the selector is not trusted to pick a
smaller matrix for its own changes. That rule is working as intended;
the classification only takes effect for later changes to these files.

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

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

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. PyYAML not installed ✓ Resolved 🐞 Bug ☼ Reliability
Description
lint.yml runs .github/scripts/lint-workflow-shell.py, but that script imports yaml (PyYAML)
and the workflow does not install it, so the job can fail immediately with ModuleNotFoundError
instead of linting workflow run: blocks. This makes the new safety check
unreliable/non-reproducible across runner images or environments.
Code

.github/workflows/lint.yml[R38-41]

+      - name: Check the linter still catches what it should
+        run: python3 .github/scripts/lint-workflow-shell.py --self-test
+      - name: Parse every workflow run block
+        run: python3 .github/scripts/lint-workflow-shell.py
Evidence
The workflow invokes the linter with python3, but there is no dependency installation step, while
the linter script imports yaml, which is not part of the Python standard library.

.github/workflows/lint.yml[33-41]
.github/scripts/lint-workflow-shell.py[48-56]

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 new workflow-shell linter script imports `yaml` (PyYAML), but the `lint.yml` workflow runs it without installing that dependency. This can cause the lint job to fail before performing any parsing checks.
### Issue Context
- `lint.yml` executes the script directly via `python3 ...`
- The repository does not otherwise install Python dependencies for CI scripts.
### Fix Focus Areas
- .github/workflows/lint.yml[33-41]
- .github/scripts/lint-workflow-shell.py[48-56]
### Suggested change
Add an explicit install step before running the linter, e.g.:
- `python3 -m pip install --upgrade pip`
- `python3 -m pip install 'PyYAML==<pinned_version>'`
(Alternatively: vendor PyYAML, or rewrite the linter to avoid third-party deps, but the simplest fix is an explicit pinned install.)

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



Remediation recommended

2. Implicit token permissions ✓ Resolved 🐞 Bug ⛨ Security
Description
lint.yml does not declare permissions, so the effective GITHUB_TOKEN permissions depend on
repo/org defaults and may be broader than required for a read-only lint job. This increases blast
radius unnecessarily if the workflow or its actions/scripts are ever compromised.
Code

.github/workflows/lint.yml[R26-29]

+  workflow-shell:
+    name: workflow run blocks parse
+    if: >-
+      github.repository == 'OpenIPC/builder' ||
Evidence
The workflow defines triggers and a job/steps but contains no permissions: stanza, meaning
permissions are not explicitly constrained in this workflow file.

.github/workflows/lint.yml[1-41]

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 lint workflow does not set an explicit `permissions:` block, making `GITHUB_TOKEN` privileges implicit and dependent on repository/organization defaults.
### Issue Context
This job checks out the repo and runs a local Python linter; it does not need write access.
### Fix Focus Areas
- .github/workflows/lint.yml[1-33]
### Suggested change
Add an explicit minimal permissions block, e.g. at workflow or job scope:

ⓘ 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 keep summaries lean with Finding overflow, which tucks the rest behind 'View more'

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread .github/workflows/lint.yml
Comment thread .github/workflows/lint.yml
Both from Qodo's review of #122.

PyYAML was an undeclared dependency. The job passed twice because
ubuntu-latest happens to ship it, which is the problem rather than the
defence: the check stops being about this repo the day the image drops
it. Installed explicitly via apt -- 24.04 is PEP 668, and it matches the
busybox install in OpenIPC/firmware's shell-tests. The import is guarded
too, so running this locally without PyYAML says what to install instead
of printing a traceback.

The workflow declared no permissions, so its token was whatever the repo
or org default is. It reads the tree and reports; contents: read.

Neither was going to fail the job today. The first fails loudly rather
than quietly if it ever fires, which is the right direction, but an
undeclared dependency and an inherited token are both the kind of thing
that is free to fix now and annoying to diagnose later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@widgetii

Copy link
Copy Markdown
Member Author

Both findings addressed in 71eaed7.

1. PyYAML not installed (High) — real, and the fact that the job passed twice is the point: ubuntu-latest happens to ship PyYAML, so the linter had an undeclared dependency on whatever the runner image includes. Now installed explicitly with apt (24.04 is PEP 668, and it matches the busybox install in OpenIPC/firmware's shell-tests). The import is also guarded, so running it locally without PyYAML prints what to install instead of a traceback — verified by hiding the module on PYTHONPATH:

lint-workflow-shell: PyYAML is required and missing.
  CI installs it (see .github/workflows/lint.yml); locally:
    apt install python3-yaml   # or: pip install pyyaml

2. Implicit token permissions (Medium) — added permissions: contents: read at workflow level. The job reads the tree and reports; it writes nothing.

Neither would have failed the job today. Both fail loudly rather than quietly if they ever fire, which is the right direction, but an undeclared dependency and an inherited token are cheap now and annoying to diagnose later.

Re-verified after the change: self-test passes, full scan clean at 23 run blocks (the new apt step adds one), the linter still flags the merged master.yml at master.yml:354 (Collect assets) with the runner's own message, and ci-matrix.py --self-test passes at 33 cases.

@widgetii
widgetii merged commit 0455db0 into master Aug 18, 2026
112 checks passed
widgetii added a commit that referenced this pull request Aug 18, 2026
The unconditional `apt-get update` added to satisfy review on #122 was
fine on the PR runs (~5s) and then sat for six minutes on the first
master push, still in progress when it was cancelled. Nothing was wrong
with the tree; apt was just slow.

That is a bad trade for this job in particular. Its argument for
existing -- and for carrying a push trigger at all -- is that it answers
in seconds, and it was made to depend on a network fetch it does not
normally need. ubuntu-latest ships PyYAML.

So import first and install only if that fails. The dependency is still
handled rather than assumed, which was the point of the review finding,
but a working runner pays nothing for it. `if` rather than
`python3 -c 'import yaml' && exit 0`, because under `bash -e` the latter
fails the step on the branch where the import fails -- exactly when the
install needs to run. Both paths checked under -e.

Also timeout-minutes: 10. The default is six hours, which is how a step
that hangs rather than fails occupies a runner and tells nobody.

The whole job now completes in three seconds, and selects 0 devices
rather than 107 because #122 classified lint.yml as no-build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
widgetii added a commit 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 added a commit to OpenIPC/firmware that referenced this pull request Aug 19, 2026
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, #123 and
#124.

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:

- A step is a mapping with a scalar `run:` that came out of a `steps:`
  sequence, which is the schema invariant and no more than that.
  Collecting any `run` key anywhere would lint an action input, an env
  var or a matrix field called `run` as shell and fail a workflow that
  is fine; requiring the `steps:` parent still leaves composite actions
  (`runs: steps:`) covered by the same rule.

- ${{ }} is not shell, so each expression is replaced with a plain word
  first. Where one ends is scanned rather than regexed: `.*?\}\}` stops
  at the first `}}` even inside a string literal, so
  `${{ fromJSON('{"a": {"b": 1}}') }}` would be cut mid-literal and the
  leftover `') }}` fails as an unbalanced quote. Line counts are
  preserved so reported lines still point at the right line.

- --self-test runs before the linter in CI and 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. A
  discovery floor fails the run if the walk stops finding blocks: a
  checker that silently checks nothing looks exactly like a clean tree.

- Syntax only. It says nothing about quoting or `-e` semantics.
  actionlint would cover more and is worth considering separately.

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 took
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant