acb2096406
Two CI-only changes ported from pi-devbox (no runtime/image impact), adapted to opencode-devbox's split-base 2-variant pipeline. Rides the next release. C — eliminate the sh-vs-bash footgun class: - Add `defaults: run: shell: bash` workflow-wide to docker-publish-split.yml and validate.yml. Gitea's default step shell is sh/dash, so bash-only syntax in a step that omits `shell: bash` fails silently. All pre-existing steps are POSIX, so bash runs them unchanged (no behavioural change). - New .gitea/workflows/lint.yml (push/PR/dispatch): a Gitea-accurate shell guard (scripts/check-workflow-shell.sh) + pinned actionlint + shellcheck. The guard closes the actionlint blind spot: actionlint models GitHub (default shell bash) so it does NOT flag bash syntax in a shell-less step. Guard scans ALL .gitea/workflows/*.yml (hence the validate.yml default too). Ported from pi-devbox 26384fe/d1db595. B — promote-base-latest re-points base-latest by digest, not need_build: The gate keyed off need_build=='true', assuming need_build==false meant base-latest was current. A dry-run dispatch that pre-builds base-<hash> falsifies that, leaving base-latest one base behind. Gate now runs on every tag release / promote dispatch; the no-op optimization moved into the step as a crane digest compare (re-tags only when base-latest != released base-<hash>). Ported from pi-devbox b7197e8. Validated locally: all 3 workflows YAML-parse; shell guard passes real workflows and correctly fails a synthetic omit-shell+pipefail workflow; actionlint (pinned 1.7.7) passes with explicit .gitea/workflows/*.yml glob.
68 lines
2.9 KiB
Bash
Executable File
68 lines
2.9 KiB
Bash
Executable File
#!/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
|