# GitHub Actions env var with special characters in a run step

> A GitHub Actions env var with special characters breaks a run step because expressions are pasted into the script before any shell reads it.

Source: https://latchkey.dev/learn/github-actions/github-actions-env-special-characters-not-escaped  
Updated: 2026-09-21

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.

```Reconstructed from a bash syntax error; the temporary script name differs on every run
/home/runner/work/_temp/f1c2.sh: line 2: unexpected EOF while looking for matching `"'
```

## 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.

```.github/workflows/ci.yml
- env:
    TITLE: ${{ github.event.pull_request.title }}
  run: printf '%s\n' "$TITLE"
```

### Quote every expansion in the script

1. Put double quotes around every variable reference that could hold text you do not control.
2. Prefer a format based print over echo for anything that might start with a dash.
3. 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.

```.github/workflows/ci.yml
- 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.

```.github/workflows/ci.yml
env:
  MSG: "a \"quote\", a $DOLLAR, a `tick`\nand a second line"
```

## 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.

## 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 |

> The third row is the whole fix. The value reaches the process through the environment, which is a channel the shell does not parse, and the script contains only a variable name.

## 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.

```.github/workflows/ci.yml
- 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"; fi
```

## Which 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.

## FAQ

### Why does quoting the expression in my run step not help?

Because the expression is substituted before any shell exists. The runner writes the evaluated text into a script file and then starts a shell on that file, so your quotes and the value end up side by side in the same text. A quote inside the value closes yours.

### How do I use a commit message in a run step safely?

Map it to an environment variable in the step `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.

### Is quoting the variable in the script enough on its own?

Only if the value reached the script through the environment. Quoting stops word splitting and filename expansion of an expansion, which is worth doing, but it cannot protect a value that was pasted into the script text before the shell started.

### Why did this step work for months and then fail?

Because the data changed rather than the workflow. The step breaks the first time somebody writes a quote, a backtick or a bracket in the branch name or commit message it reads, which is why the failure looks random and is not.

## References

- [GitHub Actions: security hardening, understanding the risk of script injections](https://docs.github.com/en/actions/reference/security/secure-use#understanding-the-risk-of-script-injections)
- [GitHub Actions: workflow syntax, jobs.<job_id>.steps[*].run](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsrun)
- [actions/runner: ScriptHandler.cs, where the script file is written and the shell started](https://github.com/actions/runner/blob/main/src/Runner.Worker/Handlers/ScriptHandler.cs)
- [GitHub Actions: variables and the env context](https://docs.github.com/en/actions/reference/workflows-and-actions/variables)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
