GitHub Actions env var with special characters in a run step
A GitHub Actions env var with special characters breaks a run step because of an ordering detail that is easy to have backwards: an expression is substituted into the script as text before any shell is started, so the shell parses your value as if you had typed it. Quoting the reference in the script does not help, because by then the value is the script.

What this error means
A run step fails with a shell syntax error that quotes a fragment of somebody commit message, a branch name or a pull request title. The message often mentions an unexpected end of file while looking for a matching quote, which is the shell telling you it reached the end of the script still waiting for something to close. The workflow is unchanged from yesterday. What changed is the data, and that is the tell: a step that breaks on one branch and works on another is being fed, not misconfigured.
/home/runner/work/_temp/f1c2.sh: line 2: unexpected EOF while looking for matching `"'The script is written first, then a shell is started
A run step is not handed to a shell as a command line. The runner evaluates the expressions in the step, writes the resulting text into a temporary script file, and then starts the shell with that file as its argument. Everything an expression produced is part of the file by the time the shell opens it.
That ordering decides the whole problem. An expression that produces a value containing a double quote does not produce a string containing a quote, it produces a script with an extra quote in it. The shell then reads a script whose quoting is unbalanced and reports a syntax error against a line number in a temporary file, which is why the error names a path under the runner work directory that you cannot open.
It also explains why the usual instinct fails. Wrapping the expression in quotes inside the script only changes where the extra quote lands. If the value can contain the character you are quoting with, no amount of quoting in the script text is safe, because the value is not being quoted, it is being pasted.
The consequence beyond broken syntax is the more serious one. A value that can close a quote can also start a command, so this is the same mechanism behind script injection through workflow data, and the fix is the same fix.
| Where the value travels | What the shell parses | Safe for arbitrary text |
|---|---|---|
| Expression pasted into the script | your value, as script text | no |
| Expression into a step env, read unquoted | the expansion, re split | no |
| Expression into a step env, read quoted | a reference to a variable | yes |
Expression into an action input under with | nothing, no shell involved | yes |
Common causes
An expression is written directly into the script body
The commonest shape, and the one that reads most naturally, which is why it survives review. The value becomes script text, so a quote in the data closes a quote in the script.
The value arrived with a newline in it
Commit message bodies and pull request descriptions are multi line. Pasted into a script, the second line becomes a second command, which can fail in a way that looks nothing like a quoting problem.
The value is read from an environment variable without quotes
Better than pasting, and still not safe. An unquoted expansion is word split and glob expanded, so a value with spaces becomes several arguments and one containing an asterisk may be replaced by file names.
The data changed, not the workflow
In our experience this is what makes the failure feel arbitrary. The step is unchanged and has worked for months, and the branch name that broke it is the first one anybody wrote a bracket in.
How to fix it
Move the expression into a step environment variable
Declare the value under the step env block and reference it in the script. The script then contains a name rather than the data, so no content in the value can change how it parses.
- env:
TITLE: ${{ github.event.pull_request.title }}
run: printf '%s\n' "$TITLE"Quote every expansion in the script
- Put double quotes around every variable reference that could hold text you do not control.
- Prefer a format based print over echo for anything that might start with a dash.
- Quote the variable inside test expressions too, since an empty unquoted value changes the shape of the test.
Pass values to actions as inputs rather than through a shell
When the destination is an action, put the expression under with. No shell parses that path, so the whole class of problem disappears rather than being contained.
- uses: some-org/notify@v1
with:
message: ${{ github.event.head_commit.message }}Reproduce with a hostile value before you believe it is fixed
Set the environment variable to a literal containing a double quote, a single quote, a dollar sign, a backtick and a newline, and run the step. A fix that survives that string will survive a commit message.
env:
MSG: "a \"quote\", a $DOLLAR, a `tick`\nand a second line"Passing the value through the environment instead
Map the expression to an environment variable on the step, then read that variable in the script with the reference quoted. The script text now contains a variable name and nothing else, so the content of the value cannot change how the script parses. The shell looks the variable up at run time and hands the contents to the command as one argument.
The quoting in the script still matters, but for a much smaller reason. An unquoted variable reference is subject to word splitting and filename expansion, so a value with spaces becomes several arguments and a value containing an asterisk may become a list of file names. Quoting the reference stops both. It cannot fix a value pasted in as text, which is why the two steps go together rather than either one alone.
For printing, prefer a command that takes a format string and treats its arguments as data. Echo behaves differently across shells with values that begin with a dash or contain backslashes, and a format based print avoids the whole category.
- name: Print the commit message safely
env:
MSG: ${{ github.event.head_commit.message }}
run: |
printf '%s\n' "$MSG"
if [ -n "$MSG" ]; then echo "message present"; fiWhich values are worth treating as hostile
Anything a person can write. Commit messages, branch and tag names, pull request titles and bodies, issue titles, review comments and the display names attached to all of them. These arrive through the event payload and they are exactly the values people paste into scripts, because they are the interesting ones.
Values that come from your own configuration deserve the same handling for a duller reason: they change. A variable holding a version string is safe until somebody puts a space in it, and the step that has worked for a year fails on the day it does.
Multi line values deserve a note of their own. A commit message body contains newlines, and a newline pasted into a script is a new command. Passed through the environment it is just a character in the value, which is the behavior you wanted.
Where a value is going to a step that takes inputs rather than to a shell, pass it under with instead. No shell is involved at all in that path, so nothing parses it.
Why there is no recorded run on this page
A recorded run would show a shell syntax error against a temporary script whose name is generated per run, and the interesting half of the evidence, the script the runner actually wrote, is not in the log. Showing the error without the generated script would be showing the least informative part of the failure.
The mechanism is better argued from the ordering itself, which is visible in how a run step is executed: expressions are evaluated into the script file, and the shell is started against that file afterwards. Once that order is clear, every symptom on this page follows from it without needing a demonstration.
There is no repair either, and it is worth being plain about why. A runner cannot re quote a script it did not write on your behalf, and guessing which fragment was data and which was code is precisely the ambiguity that caused the failure.
How to prevent it
- Make it a review rule that no expression is written directly into a run script.
- Route every value from the event payload through a step environment variable.
- Quote every shell expansion, without exception, in generated scripts.
- Test steps that handle free text with a deliberately hostile value.
Frequently asked questions
Why does quoting the expression in my run step not help?
How do I use a commit message in a run step safely?
env block and read that variable with the reference quoted. The value then travels through the environment, which the shell does not parse, and the script contains only the variable name.