Why We Wrote Our Own Workflow Expression Engine — and Have Not Regretted It

Six months of writing a PEG parser because we couldn't accept the "almost the same" 80% of GitHub Actions expressions. What we cut, what we added, what it actually cost.

John Baek
John Baek
Founder, CollabOps
Why We Wrote Our Own Workflow Expression Engine — and Have Not Regretted It

In the first week of our beta, a customer engineer messaged Slack:

"Why doesn't ${{ steps.x.outputs.y }} work? It does on GitHub."

That one line ate half my day and changed every decision we made that week. From that day on, we started writing the expression engine ourselves.

Why we didn't borrow

We tried first. Three candidates:

  • Port GitHub Actions' expression library. MIT-licensed, fine. But moving Go into our Rust runtime was heavier than expected.
  • Embed Tengo / Starlark / CEL. Way too much. We wanted six functions, not eighty.
  • JSON-e / yq. DSL-shaped, weak on arithmetic and conditions.

So: PEG (Parsing Expression Grammar), written ourselves. The first prototype ran in three days. That was the trap.

First trap — the "almost the same" 80%

We never planned to perfectly clone GitHub Actions expression syntax. But we did expect most existing workflows to just work. That was wrong.

# Things our v1 didn't handle
steps.build.outputs['package-name']         # bracket access
toJson(github.event)                        # nested call with object literal
contains(fromJson(steps.x.outputs), 'a')    # higher-order

Each one demanded a different parser decision. Bracket access is a lexer rule. Nested calls are grammar precedence. Higher-order needs the type system to allow it. We never advertised 80% compatibility, and the customer still sent us a report: 8 of 70 workflows didn't run.

8 / 70 = 11.4%. That was the migration cost we had volunteered for.

What we did

Three decisions.

One — we redefined compatibility as a byproduct, not a goal. We're building our expression language. GitHub compatibility falls out naturally if our language is a superset of theirs. Reframing this as one mental model change aligned six months of work.

Two — we made the function whitelist public. v1: six functions (contains, startsWith, endsWith, format, join, toJson). v2: eight (fromJson, hashFiles added). Adding requires discussion. Removing requires six months of deprecation. This is what prevents quiet breakage.

Three — we put a lightweight type system in. GitHub expressions are dynamic. They break at runtime. We enforce type checking at workflow definition time. This turned out to be our biggest differentiator — you know expression errors before you run a build.

User wrote:        ${{ contains(steps.x.outputs.foo, 42) }}

Type check says:   contains()'s second argument is string. 42 (number) given.
                   Verify steps.x.outputs.foo is always string.

Six months in — the real cost

ItemWrote it ourselvesBorrowed (hypothetical)
First six weeks100% (PEG, lexer, eval)30% (adapter code)
Customer compat issuesOur problem — fixed quicklyLibrary's problem — workaround or PR
Differentiator (type checking)+100 hoursNear impossible
Six-month LOC2,800 (incl. parser)~800 (adapter)
Six-month bug reports11Unknowable (external bugs land on us anyway)

We wrote 3× the code we would have. But that 3× is code we control. One library bug we can't fix at month seven justifies the choice.

What we'd reconsider

At the time of writing, six months in, zero regret. The signal that would have made us reconsider — us repeatedly working around features we couldn't add to a library — never showed up.

I'll concede one thing: the fast-running three-day prototype gave us a brief false impression that this would be quick. The real work was six weeks after that.

Who this matters to

  • Teams building a workflow domain. An expression engine almost always looks expensive but is the cheapest decision. The cost compounds when external library assumptions diverge from your domain.
  • Teams using compatibility as a sales argument. 100% compatibility is a marketing decision, not a technical one. Walk in knowing what it costs.

The next post covers how we kept the type system lightweight and how we aligned it with MCP tool schemas.

Tags#cicd#parser#peg#expression#workflow#infrastructure