From 26384fe9f11beceaf76d13af660339db26d22351 Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 1 Jul 2026 22:05:04 +0200 Subject: [PATCH] ci: eliminate the sh-vs-bash footgun class (defaults + lint guard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the recurring 'Illegal option -o pipefail' failures (ed49b8d resolve-versions; b7197e8 promote-base-latest, run 418): docker-publish.yml had no workflow-level default shell, so Gitea's sh/dash default applied and every bash-syntax step had to individually remember 'shell: bash'. - docker-publish.yml: add 'defaults: run: shell: bash' — fixes the whole class; all pre-existing dash steps are POSIX so bash runs them unchanged. - lint.yml: new workflow, runs on every push/PR (not just release tags): * scripts/check-workflow-shell.sh — Gitea-accurate guard: fails if any run: step doesn't resolve to bash. Catches the omit-shell+bash-syntax case that actionlint MISSES (actionlint models GitHub, where the default shell is bash, so a shell-less step is assumed bash). * actionlint + shellcheck — catches explicit 'shell: sh' + bash syntax (SC3040) and general workflow errors. Verified locally: guard + actionlint pass current workflows; guard fails a synthetic omit-shell+pipefail workflow; shellcheck clean. --- .gitea/workflows/docker-publish.yml | 10 +++++ .gitea/workflows/lint.yml | 64 ++++++++++++++++++++++++++++ CHANGELOG.md | 25 +++++++++++ scripts/check-workflow-shell.sh | 65 +++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 .gitea/workflows/lint.yml create mode 100755 scripts/check-workflow-shell.sh diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index 2591d84..d75243f 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -38,6 +38,16 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +# Gitea Actions' default step shell is `sh -e {0}` (dash), which rejects +# bash-only syntax like `set -o pipefail`, `[[ ]]`, and arrays. Setting the +# default to bash workflow-wide eliminates the whole class of "forgot +# `shell: bash` on this step" bugs (hit twice: ed49b8d resolve-versions, +# b7197e8/b33e9dc promote-base-latest). All existing dash steps use only +# POSIX syntax, so bash (a superset) runs them unchanged. +defaults: + run: + shell: bash + env: BUILDKIT_PROGRESS: plain IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml new file mode 100644 index 0000000..00bda6e --- /dev/null +++ b/.gitea/workflows/lint.yml @@ -0,0 +1,64 @@ +name: Lint workflows + +# Durable guard against CI-workflow bugs — most importantly the recurring +# "bash-only syntax under the default `sh`/dash shell" footgun that broke +# resolve-versions (ed49b8d) and promote-base-latest (b7197e8 → run 418). +# actionlint runs shellcheck against each `run:` step using its *effective* +# shell, so `set -o pipefail` under dash is flagged as SC3040 before any +# expensive build runs. This is cheap (~10s) and independent of the build +# pipeline, so it fires on every push/PR — not just on release tags, which +# is where the build workflow (docker-publish.yml) is otherwise only +# triggered. +on: + push: + pull_request: + workflow_dispatch: + +concurrency: + group: lint-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + actionlint: + runs-on: ubuntu-latest + container: + image: catthehacker/ubuntu:act-latest + steps: + - uses: actions/checkout@v4 + + - name: Install shellcheck + run: | + apt-get update + apt-get install -y --no-install-recommends shellcheck python3-yaml + + - name: Gitea shell guard (catches the actionlint blind spot) + # actionlint models GitHub Actions, where the default run shell is + # bash, so it does NOT flag bash syntax in a step that merely OMITS + # `shell:` — which is exactly how ed49b8d and b7197e8 manifested on + # Gitea (default sh/dash). This guard enforces that every run: step + # resolves to bash under Gitea's real defaults. Run it BEFORE + # actionlint so the more precise diagnostic surfaces first. + run: bash scripts/check-workflow-shell.sh .gitea/workflows + + - name: Install actionlint (pinned) + env: + ACTIONLINT_VERSION: 1.7.7 + run: | + curl -fsSL \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + | tar -xz -C /usr/local/bin actionlint + actionlint --version + + - name: Run actionlint + # SHELLCHECK_OPTS excludes pure-style codes (quoting/style opinions) + # so the guard stays focused on correctness bugs — crucially the + # SC3xxx "not POSIX / wrong shell" family that catches the pipefail + # footgun. Do NOT exclude SC3040 (set -o pipefail under sh) or any + # other SC3xxx code. + env: + SHELLCHECK_OPTS: "-e SC2086 -e SC2016 -e SC2129 -e SC2001 -e SC2312" + run: actionlint -color diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ded665..3a5cec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,31 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`). ## Unreleased +### Added (CI) + +- **Workflow lint (`.gitea/workflows/lint.yml`) running on every push and PR.** + Two complementary checks, so CI-workflow bugs are caught before an expensive + build runs: + - **`scripts/check-workflow-shell.sh`** — a Gitea-accurate guard that fails + if any `run:` step doesn't resolve to `bash` under Gitea's real defaults. + This catches the exact recurrence class (omit `shell:`, use bash syntax), + which **actionlint alone does not** — actionlint models GitHub Actions + (default shell = bash) and so assumes a shell-less step is bash, whereas + Gitea's default is `sh`/dash. + - **`actionlint` + `shellcheck`** — catches explicit `shell: sh` + bash + syntax (SC3040 etc.), expression errors, and general workflow mistakes. + Style-only shellcheck codes are excluded; the SC3xxx "wrong shell" family + is kept. + +### Changed (CI) + +- **Workflow-level `defaults: run: shell: bash` in `docker-publish.yml`.** + Gitea Actions defaults each `run:` step to `sh` (dash), so every bash-syntax + step had to individually remember `shell: bash` — a discipline requirement + that failed twice (ed49b8d, b7197e8). Setting the default workflow-wide + eliminates the whole class. All pre-existing dash steps use only POSIX + syntax, so bash (a superset) runs them unchanged. + ### Fixed (CI) - **`promote-base-latest` now sets `shell: bash` on the base-latest re-tag diff --git a/scripts/check-workflow-shell.sh b/scripts/check-workflow-shell.sh new file mode 100755 index 0000000..0d3ba41 --- /dev/null +++ b/scripts/check-workflow-shell.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Gitea-accurate guard against the recurring "bash syntax under the default +# sh/dash shell" footgun (ed49b8d resolve-versions; b7197e8 promote-base-latest, +# run 418). +# +# WHY A CUSTOM CHECK AND NOT JUST actionlint: +# actionlint models *GitHub* Actions, whose default `run` shell is bash. It +# therefore assumes a step that omits `shell:` runs under bash, and does NOT +# flag `set -o pipefail` there. Gitea Actions' default is `sh` (dash), so the +# exact bug we hit (omit `shell:`, use bash syntax) is invisible to actionlint. +# actionlint only fires when a step *explicitly* declares `shell: sh`. +# +# THE INVARIANT THIS ENFORCES: +# Every `run:` step in every .gitea/workflows/*.yml must resolve to an +# effective shell of `bash` — via the step's own `shell:`, a job-level +# `defaults.run.shell`, or a workflow-level `defaults.run.shell`. Any step +# that would fall through to Gitea's `sh` default is a FAILURE, because a +# future author adding bash syntax to it fails silently in CI. +# +# Pair this with actionlint (which catches explicit `shell: sh` + bash syntax, +# expression errors, and much else). Together they cover the class on Gitea. +set -euo pipefail + +WF_DIR="${1:-.gitea/workflows}" + +python3 - "$WF_DIR" <<'PY' +import sys, glob, os +try: + import yaml +except ImportError: + sys.stderr.write("ERROR: python3 yaml module missing (apt install python3-yaml)\n") + sys.exit(2) + +wf_dir = sys.argv[1] +files = sorted(glob.glob(os.path.join(wf_dir, "*.yml")) + glob.glob(os.path.join(wf_dir, "*.yaml"))) +if not files: + sys.stderr.write(f"ERROR: no workflow files under {wf_dir}\n") + sys.exit(2) + +problems = [] +for f in files: + with open(f) as fh: + doc = yaml.safe_load(fh) or {} + wf_shell = (((doc.get("defaults") or {}).get("run") or {}).get("shell")) + jobs = doc.get("jobs") or {} + for jname, job in jobs.items(): + job = job or {} + job_shell = (((job.get("defaults") or {}).get("run") or {}).get("shell")) + steps = job.get("steps") or [] + for i, step in enumerate(steps): + step = step or {} + if "run" not in step: + continue # `uses:` steps have no shell + eff = step.get("shell") or job_shell or wf_shell or "sh" # Gitea default = sh + if eff != "bash": + name = step.get("name") or f"step[{i}]" + problems.append(f"{f}: job '{jname}' / '{name}': effective shell = '{eff}' (Gitea default is sh; declare shell: bash or a bash default)") + +if problems: + sys.stderr.write("Workflow shell guard FAILED — bash default not guaranteed:\n") + for p in problems: + sys.stderr.write(f" - {p}\n") + sys.exit(1) +print(f"Workflow shell guard OK — all run: steps in {len(files)} workflow file(s) resolve to bash.") +PY