`printf: write error: Broken pipe` on a self-hosted GitHub runner? Check SIGPIPE
A bash pipeline that is quiet locally but fails only on one self-hosted runner may be a signal-disposition problem, not a data or code problem. Our required test failed in unrelated files with printf: write error: Broken pipe, and every failure landed on the same Linux runner.
The useful check is the effective job-shell behavior, not only the runner listener:
systemctl show actions.runner.SHUKE-LABS.<runner>.service -p IgnoreSIGPIPE
PATH=/usr/local/libexec/actions-runner:$PATH bash -c '
set -o pipefail
printf "%s\n" {1..10000} | head -n1 >/dev/null
printf "pipeline rc=%s\n" "$?"
'
The normal result is a SIGPIPE exit (usually 141). With SIGPIPE inherited as SIG_IGN, bash's builtin printf receives EPIPE instead, prints the broken-pipe diagnostic, and pipefail turns it into a failure.
The fix has two parts. Add a systemd drop-in:
[Service]
IgnoreSIGPIPE=no
Then put a root-owned launcher first in the runner's .path file:
#!/usr/bin/perl
$SIG{PIPE} = "DEFAULT";
exec "/usr/bin/bash", @ARGV or die "exec bash: $!";
The launcher matters because the Actions Runner's Node/Listener/Worker chain can set SIGPIPE to ignored again after systemd starts the service. Verify with a fresh job shell and the pipeline test; the listener process's SigIgn alone is not a reliable success criterion.
Check the whole Linux fleet, not only the runner named in the first failure. The same defect was present on all three sydney2 runners and on the mbp15-vm Lima guest. After fixing the signal boundary, two independent failures surfaced: a jq 1.6-incompatible test mock and newer-main tmux/test timing regressions. Keeping those layers separate prevented a correct runner diagnosis from being mistaken for a complete CI diagnosis.
The final CI-shaped local run passed 166/166, and the GitHub required jobs passed after the fleet fix.