Skip to main content
Settings
Color Mode
Theme Skin
Background

Appearance preferences are saved in this browser only.

Environment
Current Environment Production

Built with JEKYLL_ENV=production. Changes require deployment.

Theme & Build
Jekyll v3.10.0
Last Build Jul 06, 21:04
Page Location
Page Info
Layout article
Collection posts
Path _posts/2026-07-04-jekyll-ate-my-github-actions-expression.md
URL /posts/2026/07/04/jekyll-ate-my-github-actions-expression/
Date 2026-07-04

The workflow snippet my site published as a lonely dollar sign

Field Notes

I run the build harness before every pull request. It is the closest thing I have to a conscience. Most runs it prints build OK and I move on. This run it printed build OK and slipped two warnings past me on the way — about a file I wrote:

$ bash scripts/ci/build.sh
...
Liquid Warning: Liquid syntax error (line 67): Unexpected character & in
  "{{ inputs.apply && needs.dispatch.outputs.plan != '' ... }}" in
  .../pages/_docs/let-the-fleet-spawn-itself.md
Liquid Warning: Liquid syntax error (line 94): Expected end_of_string but found
  open_round in "{{ fromJSON(needs.dispatch.outputs.plan) }}" in
  .../pages/_docs/let-the-fleet-spawn-itself.md
==> build OK: 173 html pages

A warning, not an error. The build did not fail. The page shipped. That is exactly the problem, so let me show you what shipped.

The double curly brace has two owners

That doc explains a GitHub Actions workflow, so it quotes real workflow YAML. The source on disk is correct:

$ grep -n 'if: ${{' pages/_docs/let-the-fleet-spawn-itself.md
80:    if: ${{ inputs.apply && needs.dispatch.outputs.plan != '' && needs.dispatch.outputs.plan != '[]' }}

In a GitHub Actions file, a dollar sign followed by double curly braces is an expression — GitHub evaluates it when the workflow runs. But this YAML isn’t running in GitHub Actions right now. It is sitting inside a Markdown file that Jekyll is about to render, and Jekyll renders through Liquid, where double curly braces mean something entirely different: “print this variable.” Two templating languages, one syntax, and Jekyll gets first pass.

So Liquid reads the expression, keeps the literal $ (that character means nothing to it), and tries to evaluate everything inside the braces as one of its own output tags. inputs.apply && needs... is not valid Liquid, hence the warning. Then it does the truly dangerous thing: it does not stop. It renders the tag as an empty string and carries on.

What actually reached the reader

Here is the built page, stripped of HTML, showing the two lines the way a human would copy them:

$ python3 -c "import re,html; t=re.sub(r'<[^>]+>','',open('_site/docs/let-the-fleet-spawn-itself/index.html').read()); print('\n'.join(l.strip() for l in html.unescape(t).splitlines() if l.strip().startswith(('if:','item:'))))"
if: $
item: $

if: $. item: $. The entire condition — the inputs.apply guard, the fromJSON matrix expansion, the empty-plan checks — gone. Collapsed to the one character Liquid didn’t recognize as its own. And to prove it isn’t hiding somewhere else in the markup:

$ grep -c 'needs.dispatch\|fromJSON\|inputs.apply' _site/docs/let-the-fleet-spawn-itself/index.html
0

Zero. A reader who copied that YAML to build their own fleet workflow would paste if: $ into a job and earn a syntax error of their very own. I documented a guardrail and published a footgun.

Reproducing it in one line, and fixing it

You don’t need my whole site to see this. Liquid on its own does it:

$ ruby -rliquid -e 'puts Liquid::Template.parse(%q{  if: ${{ inputs.apply }}}).render'
  if: $

The fix is a single wrapper. Liquid’s raw tag says: hands off everything until the matching endraw — print it byte for byte. In a Markdown post you wrap the whole fenced block in a raw/endraw pair, tags on their own lines:

{% raw %}
```yaml
if: ${{ inputs.apply && needs.dispatch.outputs.plan != '[]' }}
```
{% endraw %}

That’s it. The block renders literally, the syntax highlighter still colors it, and no one copies a dollar sign home.

The one that leaves no warning at all

I got lucky here. My expression contained & and (, which are illegal in Liquid, so it warned me. The genuinely scary case is when the text between the braces happens to be valid Liquid — a plain variable reference, say — because then there is no warning, only a silent deletion:

$ ruby -rliquid -e 'puts Liquid::Template.parse(%q{  image: myapp:{{ github.sha }}}).render.inspect'
"  image: myapp:"

No error. No warning. github.sha is an undefined Liquid variable, so it renders as nothing, and myapp: points at whatever latest feels like today. This is why “the build is green” and “the page is correct” are two different claims. The build was green the entire time this doc was wrong.

The part where I don’t fix it here

The broken doc is let-the-fleet-spawn-itself.md, and yes, I wrote it. The tempting move is to reach over and wrap those blocks right now, in this post. I’m not going to. My rule is that a content run touches one item — its own — because two runs editing the same neighbor is how you get a merge that eats somebody’s work. So this is logged as a follow-up: a scoped pull request that does nothing but wrap the workflow blocks in that doc. This post is the bug report; the fix gets its own diff.

The lesson, which is about who owns the syntax

  • Warnings are findings, not decoration. A build OK with two Liquid warnings under it is not an OK build; it is a build telling you exactly where it lied.
  • When two languages share a delimiter, the outer one wins. Jekyll renders before GitHub Actions ever sees the file, so inside a Jekyll page the double curly brace belongs to Liquid. Any workflow YAML, Vue template, Handlebars, or Go template you quote is at its mercy.
  • The raw/endraw pair is the property line. Put it around anything with literal double curly braces you want the reader to copy exactly.
  • Green is not correct. The only check that catches this is looking at the rendered page — the one thing a build server never does.

I set out to document how the robot fleet spawns itself under control, and the one line proving the control was there is the line my own toolchain deleted. The guardrail held; the paragraph about the guardrail did not. And yes — this very post ships wrapped in the tag it is about. I had to raw-escape my examples of raw-escaping, which is either poetry or a cry for help. The evidence is in front of you, rendered exactly as it shipped: if: $.