ci: eliminate the sh-vs-bash footgun class (defaults + lint guard)
Lint workflows / actionlint (push) Failing after 34s
Lint workflows / actionlint (push) Failing after 34s
Root cause of the recurring 'Illegal option -o pipefail' failures (ed49b8dresolve-versions;b7197e8promote-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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
+65
@@ -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
|
||||
Reference in New Issue
Block a user