diff --git a/.github/workflows/shell-ci.yml b/.github/workflows/shell-ci.yml index d21d501..495685d 100644 --- a/.github/workflows/shell-ci.yml +++ b/.github/workflows/shell-ci.yml @@ -31,6 +31,10 @@ on: description: Space-separated apt packages the test suite needs type: string default: "" + check-exec-bit: + description: Fail when a script the workflows invoke as ./path is not executable in git + type: boolean + default: true permissions: contents: read @@ -107,6 +111,57 @@ jobs: set -euo pipefail xargs -0 -r shellcheck --severity="$SEVERITY" < "$RUNNER_TEMP/shell-files" + # A script invoked as `./path` needs the executable bit recorded IN GIT. + # `chmod +x` in a working tree is local state; the mode git stores is what + # reaches the runner, and when it disagrees the job dies with exit 126 and + # "Permission denied" — a message about the file, not about the mode, which + # is why it costs a round trip to diagnose every time. + # + # This happened twice in one day in Glyndor/apt (2026-08-23): once on + # scripts/build-installers.sh, once on tests/build-installers.test.sh. + # + # Deliberately narrow. It does NOT assert that every `.sh` is 0755 — that + # would be wrong. `apt/scripts/install-template.sh` is a template that is + # read and substituted, never executed, and is correctly 0644; and repos + # that invoke their scripts as `bash path` (podup, helmly, unitpm — 21 + # non-executable scripts between them) have nothing to fix. The check is + # derived from how the workflows actually call each script, so a repo that + # never uses `./path` finds nothing and passes. + # + # Default is on rather than opt-in: the SHA pin is the buffer, so no + # consumer sees this until it deliberately bumps its pin, which is the + # moment to look at a new red. + - name: Scripts invoked by path must be executable in git + if: ${{ inputs.check-exec-bit }} + run: | + set -euo pipefail + # Every `./something.sh` mentioned anywhere in the calling repo's + # workflows. Paths are relative to the repo root, which is where the + # runner starts, so that is what the mode is checked against. + refs="$(grep -rhoE '\./[A-Za-z0-9_./-]+\.(sh|bash)' .github/workflows/ 2>/dev/null \ + | sed 's|^\./||' | sort -u || true)" + if [ -z "$refs" ]; then + echo "No script is invoked as ./path by any workflow; nothing to check." + exit 0 + fi + rc=0 + while IFS= read -r f; do + [ -n "$f" ] || continue + mode="$(git ls-files --stage -- "$f" | awk '{print $1}')" + if [ -z "$mode" ]; then + echo "note: $f is invoked as ./$f but is not tracked; skipping." + continue + fi + if [ "$mode" != "100755" ]; then + echo "::error file=$f::$f is invoked as ./$f but git records mode $mode; the job will fail with exit 126 (Permission denied). Fix with: git update-index --chmod=+x $f" + rc=1 + fi + done <