The day YAML truncated my bash script
A GitHub Actions error pointing to line 76. A bash script silently cut in half. One line at column 0, invisible to yamllint.
GitHub Actions refused to run. The error: "You have an error in your yaml syntax on line 76". Line 76 looked perfect. No odd indentation, no suspicious character.
This kind of error is worse than a stacktrace. A stacktrace tells you where to look and what crashed. Here, we have the line, but not why that line is a problem.
This workflow automatically closed GitHub issues referenced in commits. At first, the bash script was five or six lines long, directly inside a run: | block. Convenient.
Except the script grew. Labels, status checks, custom messages: thirty lines, then forty, still inside the YAML. In my editor, the indentation looked correct. No warning sign.
I pushed. Same bug as a week before. I fixed it, pushed again. Same bug.
The culprit was tiny: the COMMENT variable held a two-line message, and the second line started at column 0.
Here is the exact workflow excerpt as it was in the YAML:
run: |
...
COMMENT="✅ This issue was automatically closed because it was resolved in recent commits.
For the commits that resolved this issue, check the git history."
gh issue close "$ISSUE_NUM" --comment "$COMMENT"The line For the commits... is at column 0. In the editor, it looks like a line break inside a bash string. In YAML, it terminates the run: | block. Everything that follows, the gh issue close, the counter, the final summary, never reaches the runner.
# After, properly indented
COMMENT="✅ This issue was automatically closed.
For the commits, check the git history."In YAML, a line at column 0 inside a literal block scalar terminates the block. The rest of the script never reaches the runner. GitHub Actions points to line 76, but without explaining what is wrong. Line 76 is a bash string continuation, it looks perfectly valid. It is the YAML context that makes it invalid, and the message does not say so.
yamllint does not catch this either. A line at column 0 inside a | block is legal YAML. The tool passes, the commit passes. GitHub Actions detects the anomaly but its error message gives neither the location nor the cause, just "error on line 76".
The indentation fix took thirty seconds. But closing the file, I realised that was not the real problem.
Forty lines of bash inside YAML means two syntaxes in the same file where every space has a different meaning depending on context. You cannot test the script locally without extracting it by hand. You cannot lint it directly. And with every change, you are one misplaced space away from a silent bug.
I extracted everything into .github/scripts/auto-close-resolved.sh. The workflow now reads:
- name: Close resolved issues
env:
GH_TOKEN: ${{ github.token }}
run: bash .github/scripts/auto-close-resolved.shThe YAML handles orchestration, the .sh handles shell. I should have done this at line twenty.
The external script has another advantage: you can run it directly on your machine to test, without simulating a runner. And you can lint it.
For that, I added an entry in .pre-commit-config.yaml via prek. The hook uses shellcheck-py (the Python wrapper for ShellCheck), configured to apply only to .github/scripts/*.sh files:
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
hooks:
- id: shellcheck
name: ShellCheck
files: ^\.github/scripts/.*\.sh$Now if I introduce a shell syntax error, I see it at commit time, not after a failed CI run. yamllint and shellcheck do not do the same job: one looks at the YAML, the other at the shell. A perfectly valid YAML can deliver a script truncated in half without complaining.
As soon as a run: | block exceeds ten lines or so, it is time to create an external .sh file.
The most costly errors are not always the ones that explain what went wrong.
A few things I should have done differently.
The first is the most obvious in retrospect: validate the bash script earlier. Most editors, Neovim, PyCharm, Zed, VS Code, have a shellcheck plugin that would have caught the issue before it ever reached the YAML. I was debugging the wrong layer.
The second is act, a tool that runs GitHub Actions locally on your own machine. Running act would have surfaced the broken workflow in seconds, without a push-wait-read-fix loop. I knew about it. I did not use it.
The third is about bash scripts themselves. I already knew that long scripts accumulate friction. That lesson did not stop me from letting the script grow to forty lines inside a YAML file.
The last point is less technical: I kept applying the same approach instead of stepping back to choose the right tool. No single tool, not AI, not a linter, not an emulator, substitutes for that pause. They all help, but only once you have picked the right angle.
act — run your GitHub Actions locally; reproduces the runner environment on your own machine.
GitHub Actions — continuous integration and deployment platform built into GitHub.
yamllint — YAML linter that checks syntax and style, but not the semantics of scalar blocks.
ShellCheck — static analyser for shell scripts (bash, sh, zsh) that detects common errors and anti-patterns.
shellcheck-py — Python wrapper for ShellCheck, usable as a pre-commit hook.
prek — Git hook manager written in Rust, drop-in alternative to pre-commit — same configuration format, no Python dependency.
pre-commit — the original tool whose
.pre-commit-config.yamlformat prek reuses.
