From e4f45a1e50a914c5ed7d82f9f1be5b5bf0ff8a54 Mon Sep 17 00:00:00 2001 From: Jaro-c <75870284+Jaro-c@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:43:31 -0500 Subject: [PATCH 1/2] feat(shell-ci): fail when a script invoked by path is not executable in git chmod +x in a working tree is local state; the mode git records is what reaches the runner. When they disagree 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. It happened twice in one day in apt: once on scripts/build-installers.sh (#116) and once on tests/build-installers.test.sh a few hours later. The check is deliberately narrow. It does not assert that every .sh is 0755, which would be wrong - apt/scripts/install-template.sh is read and substituted, never executed, and is correctly 0644. It derives the list from how the calling repo workflows actually invoke each script, so a repo using `bash path` finds nothing. Measured: apt, homebrew-tap and scoop-bucket invoke by path; podup, epistle, helmly, unitpm and glyndor.net do not, and have 21 non-executable scripts between them that are not defects. Default on rather than opt-in, because the SHA pin is the buffer: no consumer sees it until it bumps its pin deliberately, which is the moment to look at a new red. I ran the logic against three real states rather than reading it: apt today passes, apt at the broken commit fails naming the file and the mode, podup reports nothing to check. Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com> --- .github/workflows/shell-ci.yml | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) 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 < Date: Sun, 23 Aug 2026 22:01:19 -0500 Subject: [PATCH 2/2] docs(reusables): regenerate after the shell-ci input docs-current caught it: the pages are generated by scripts/render-reusable-docs.py and the check fails when the committed ones disagree with the workflows. Adding check-exec-bit made shell-ci.md stale, which is the gate working as designed. Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com> --- docs/reusables/README.md | 2 +- docs/reusables/shell-ci.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reusables/README.md b/docs/reusables/README.md index 3de1f02..2eb71de 100644 --- a/docs/reusables/README.md +++ b/docs/reusables/README.md @@ -29,7 +29,7 @@ which blocks every pull request until someone works out why. | [`rust-supply-chain`](rust-supply-chain.md) | 1 | 0 | 5 | | [`rust-toolchain-watch`](rust-toolchain-watch.md) | 1 | 0 | 1 | | [`schedule-freshness`](schedule-freshness.md) | 1 | 0 | 2 | -| [`shell-ci`](shell-ci.md) | 2 | 1 | 6 | +| [`shell-ci`](shell-ci.md) | 2 | 1 | 7 | | [`workflow-lint`](workflow-lint.md) | 1 | 0 | 2 | *Conditional* checks only run when an input turns them on, so they emit no diff --git a/docs/reusables/shell-ci.md b/docs/reusables/shell-ci.md index 689ab12..524ef3f 100644 --- a/docs/reusables/shell-ci.md +++ b/docs/reusables/shell-ci.md @@ -42,6 +42,7 @@ check is created. | `shellcheck-sha256` | string | `8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198` | no | sha256 of that release's linux.x86_64 tarball; travels with shellcheck-version, so overriding one without the other fails the install | | `test-command` | string | — | no | Command running the repository's shell test suite (skipped when empty) | | `apt-packages` | string | — | no | Space-separated apt packages the test suite needs | +| `check-exec-bit` | boolean | `true` | no | Fail when a script the workflows invoke as ./path is not executable in git | ---