#!/usr/bin/env bash # Gitea-accurate guard against the "bash syntax under the default sh/dash # shell" footgun. Ported from pi-devbox, where this class bit twice # (ed49b8d resolve-versions; b7197e8/b33e9dc promote-base-latest, run 418). # opencode-devbox has not been bitten yet — this is a PREVENTIVE guard so a # future author can't reintroduce the class. # # 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 (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