A GitHub Actions step that runs a shell script from the repo failed on its very first line:
Run ./scripts/package-smoke-test.sh --no-clean-build
/home/runner/work/_temp/a273ee7a-2f87-48a3-b837-d2a83a78b30d.sh: line 1: ./scripts/package-smoke-test.sh: Permission denied
Error: Process completed with exit code 126.
I had run chmod +x on that file before committing it, so Permission denied was not the failure I expected. The script is there in the checkout, the path in the workflow is right, and the runner still refuses to run it.
The exit code narrows this down before you investigate anything. Bash defines both cases precisely: a command that is found but not executable returns 126, and one that cannot be found at all returns 127. So the checkout and the path are not the problem, and the file has arrived on the runner as mode 100644.
The exec bit lives in git’s index, not in your working tree
Git tracks exactly one bit of permission information per file: 100644 for a regular file and 100755 for an executable one. chmod +x changes the filesystem, and there are two ordinary ways for that change never to reach the index. Two commands tell you which one you are looking at, git ls-files -s to read the staged mode and git config to read the setting that may have discarded it:
git ls-files -s scripts/package-smoke-test.sh
git config core.fileMode
If the first prints 100644, the diagnosis is confirmed: the committed file is not executable, whatever ls -l says locally. If the second prints false, that is your cause. With core.fileMode disabled, git ignores the executable bit on tracked files, so the chmod +x was real on disk and completely invisible to git add, git status and git diff. Plenty of people turn that setting off after a filesystem or a Docker bind mount spams them with mode-only diffs on every file, and then it sits there quietly for years until a script needs to run in CI.
If core.fileMode is unset or true, the explanation is the more mundane one: you staged the file with git add, ran chmod +x afterwards, and committed the staged copy that predated it.
The fix
Write the mode straight into the index:
git update-index --chmod=+x scripts/package-smoke-test.sh
git commit -m "chore: mark package smoke test executable"
git ls-files -s scripts/package-smoke-test.sh
That last check should now show 100755. git update-index --chmod is the command worth remembering here because it works regardless of core.fileMode: it edits the index directly rather than reading a mode off the filesystem, so it does the right thing even on a repo where git has been told to ignore permissions.
Better still: stop making CI depend on the bit
Push and the step will pass, but I would not leave the workflow calling the script directly. Invoke the interpreter instead:
- name: Smoke test the package
shell: bash
run: bash ./scripts/package-smoke-test.sh --no-clean-build
It costs nothing and it takes the exec bit out of the set of things that can break the step. That matters more than it sounds when the script in question is a gate: this one is the packaging check from the release that shipped without a UI, so I want it failing for genuine packaging reasons and nothing else. It also means a contributor whose checkout mangles file modes still gets a working script.
On Windows runners, the same script has two more problems
If you run it on windows-latest as well, expect to hit these next.
The default shell on Windows runners is PowerShell, and ./scripts/package-smoke-test.sh in PowerShell does not invoke bash at all. It tries to execute the file and errors out. So shell: bash is required rather than merely tidy, and the bash ./script.sh form matters even more, because NTFS has no exec bit for git to restore in the first place. This is the same platform difference that makes an inline NODE_ENV=test in an npm script fail on Windows: the command is fine, the shell reading it is not the one you assumed.
Then there are line endings. With no .gitattributes in the repo, Git for Windows applies its shipped core.autocrlf=true, so the runner checks the script out with CRLF terminators. The shebang then reads #!/usr/bin/env bash\r, bash goes looking for an interpreter literally named bash\r, and you get bad interpreter: No such file or directory while bash is obviously sitting right there. Calling it as bash ./script.sh sidesteps the shebang, but the stray \r at the end of every other line still corrupts command substitutions and comparisons. Fix it at the repo level:
* text=auto eol=lf
*.sh text eol=lf
Then run git add --renormalize . and commit, which rewrites the already-tracked files rather than waiting for someone to touch them.
The takeaway
126 means found-but-not-executable and 127 means not-found. That single distinction saves you from checking a path that was never wrong. Since the exec bit is a property of git’s index and core.fileMode can quietly discard your local chmod, fix the file with git update-index --chmod=+x and then stop relying on it: bash ./script.sh in the workflow means the question cannot come up again, on any runner.