Skip to content

Ansible Jinja {% include %} Glues the Partial's Last Line to the Next

Sharing a template partial across roles with {% include 'partial.j2' %} has a newline trap that can ship syntax-broken files fleet-wide while every local test passes.

The mechanism

Two Jinja behaviors stack:

  1. Jinja's loader strips the final newline of any loaded template — including included partials. The partial's output ends mid-line.
  2. Ansible renders with trim_blocks=True, which removes the newline immediately after a block tag — including the one after {% include ... %}.

Result: the partial's last line and whatever follows the include site render onto one line. For a shell partial ending in a variable assignment:

GRAFANA_LOGS_URL="https://..."BODY+="\nFleet logs: ${GRAFANA_LOGS_URL}\n"

That happens to be valid bash (one long assignment word) — the script parses, runs, and the footer silently never reaches the mail body. In other files the same glue produced hard syntax errors (syntax error near unexpected token 'then'). Both flavors shipped to 9 hosts here before an on-box check caught them.

Why local tests lied

A render test using jinja2.Environment() defaults has trim_blocks=False — the newline after the include tag survives, the render is correct, bash -n passes. Ansible never produces that render.

The fixes

1. Guard the partial — end it with a sacrificial Jinja comment line. The loader strips the comment's newline; the real last line keeps its own:

LAST_REAL_LINE="..."
{# newline guard — Jinja strips the file-final newline and Ansible's
   trim_blocks eats the one after the include tag; without this line the
   assignment above glues onto the include site's next statement. #}

2. Test with Ansible's actual settings:

jinja2.Environment(loader=..., trim_blocks=True, lstrip_blocks=False,
                   keep_trailing_newline=True)

and add an explicit glued-line assertion (e.g. regex for " immediately followed by a non-whitespace statement).

3. Gate the deploy on the deployed files — bash -n the rendered scripts on the hosts, not just local renders. Run it as root: a bash -n that can't read a 0700 script reports failure that looks identical to a syntax failure.

Sharing partials across roles at all

Jinja resolves includes from the parent template's own directory, so a cross-role partial works cleanly as: one canonical file at the repo root, symlinked into each role's templates/ dir. Git preserves symlinks; no loader path hacks needed.