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.
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-orderEach 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
| Item | Wrote it ourselves | Borrowed (hypothetical) |
|---|---|---|
| First six weeks | 100% (PEG, lexer, eval) | 30% (adapter code) |
| Customer compat issues | Our problem — fixed quickly | Library's problem — workaround or PR |
| Differentiator (type checking) | +100 hours | Near impossible |
| Six-month LOC | 2,800 (incl. parser) | ~800 (adapter) |
| Six-month bug reports | 11 | Unknowable (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.
Related posts
When a Product Manager Ships Code, Who Owns the Outage?
In organizations where product managers write code with AI and several agents work at once, who checks what before a change reaches production? A role design for verification, approval, and recovery that is independent of the author, grounded in NIST SSDF, SLSA provenance, and the Google SRE postmortem culture.
John Baek
An Eclipse Plugin for the Agent Era — Task Context Was Already the Problem 20 Years Ago
The problem Mylyn set out to solve in the mid-2000s is the agent context problem. What changed is that the thing reading that context is no longer only a person.
Yeongsang Kim
A VS Code Extension for the Agent Era — What Developers Look At Now
Once writing code got cheap, a developer's time moved to judging and approving. Here is why those jobs cannot live outside the editor, and the choices behind the CollabOps VS Code extension.
Seungbaek Lee