Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions .github/workflows/shell-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <<EOF
$refs
EOF
[ "$rc" -eq 0 ] && echo "Every script invoked by path is executable in git."
exit "$rc"

test:
name: test
if: inputs.test-command != ''
Expand Down
2 changes: 1 addition & 1 deletion docs/reusables/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/reusables/shell-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
Loading