Files
openipc-builder/.github/workflows/lint.yml
T
0455db0027 ci: close the if that cost last night's release, and parse-check the rest (#122)
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. actionlint would cover more and is worth
  considering later; keeping this narrow is what makes it noise-free now.

ci-matrix.py classifies both new files as no-build. They were names it
had never heard of, and unknown widens by design, so the first push
queued all 107 devices to prove a one-line YAML fix -- #121, which
touched only master.yml, ran 19. 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.

PyYAML is installed explicitly rather than inherited from the runner
image, and the workflow declares `contents: read` rather than taking the
repo default. Both from review; neither would have failed the job today.

Verified: the checker flags the merged master.yml with the same message
the runner gave, passes the fixed tree, 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>
2026-08-18 18:44:03 +03:00

57 lines
2.4 KiB
YAML

name: lint
on:
pull_request:
branches:
- master
# A push trigger, unlike shell-tests in OpenIPC/firmware, which deliberately
# has none. The reasoning there -- every commit on master was already checked
# on its pull request -- holds only while master has not moved between the
# PR's last run and the merge. This job is the one thing standing between a
# workflow typo and a silently lost nightly, it costs seconds, and #121 cost
# a full night's release because nothing re-checked the merged result. Cheap
# enough to not be clever about.
push:
branches:
- master
paths:
- '.github/workflows/**'
- '.github/scripts/lint-workflow-shell.py'
workflow_dispatch:
# Reads the tree and reports; writes nothing. Declared rather than inherited so
# the job keeps the narrow token whatever the repo or org default becomes.
permissions:
contents: read
jobs:
# Same guard the jobs in master.yml carry. A clone pushed to a new repository
# inherits this file, and on a private mirror every master sync push would
# otherwise run on that owner's bill. Upstream the first disjunct is true, so
# the condition is a tautology here.
workflow-shell:
name: workflow run blocks parse
if: >-
github.repository == 'OpenIPC/builder' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && !github.event.repository.private)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ubuntu-latest ships PyYAML today, so the linter imports it fine without
# this -- which is the problem. An undeclared dependency on whatever the
# runner image happens to include is a check that stops being about this
# repo the day the image changes. apt rather than pip because 24.04 is
# PEP 668 and this is the same shape as the busybox install in
# OpenIPC/firmware's shell-tests.
- name: Install PyYAML
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq python3-yaml
# Checks the checker before trusting it. The ${{ }} substitution it has to
# do is the kind of thing that breaks by making everything pass, which
# would look identical to a clean tree.
- 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