tl;dr
I built ghawb, a TypeScript-first authoring library for GitHub Actions workflows and composite actions. It keeps GitHub Actions explicit, but moves workflow authoring into typed, validated, testable code. You still commit normal YAML; you just do not have to hand-author all of it.
Prologue
I like GitHub Actions.
I do not like finding out that I made a trivial mistake only after I pushed a branch, waited for CI to boot, and then watched a workflow fail because I wrote the wrong shape of permissions, mixed up a trigger filter, or typoed an output reference buried in YAML.
That feedback loop is too late.
So I built ghawb: a TypeScript library for authoring GitHub Actions workflows and composite actions with:
- type-safe builders
- validation at construction time
- deterministic YAML rendering
- source-first distribution through JSR
The idea is simple:
if a workflow is code, then at least some workflow mistakes should be regular programming errors
Instead of treating .github/workflows/*.yml as the source of truth, ghawb treats TypeScript as the source and renders committed YAML from it.
The problem I wanted to fix
GitHub Actions YAML has an awkward failure mode.
A lot of mistakes are not conceptually complicated, but they are still easy to make:
- invalid combinations of trigger fields
- blank or malformed IDs
- step output references that point to nothing
- reusable workflow jobs using fields GitHub does not allow there
- matrix definitions that look plausible but are structurally wrong
None of these are interesting problems.
They are structural mistakes.
And structural mistakes are exactly the kind of thing a type system and a validation layer should help with.
What ghawb looks like
Here is a small CI workflow:
import { createJobId, createWorkflowId, defineWorkflow } from "@ghawb/sdk";
import { nodeCi } from "@ghawb/job-helpers";
const workflow = defineWorkflow({
id: createWorkflowId("ci"),
name: "CI",
})
.onPush({ branches: ["main"] })
.onPullRequest({ branches: ["main"] })
.addJob(createJobId("test"), (job) => {
job.runsOn("ubuntu-latest").apply(nodeCi({ nodeVersion: "24" }));
})
.build();
Then render it with the CLI:
bun x @ghawb/cli render --input workflows/ci.ts --output .github/workflows/ci.yml
The output is deterministic, so you can commit the generated YAML and review it normally.
The important part is not that YAML is generated.
The important part is that the authoring surface is now typed, validated, and testable.
The goal is not to hide GitHub Actions
I was not trying to pretend GitHub Actions is something other than GitHub Actions.
The goal is narrower:
- keep the GitHub Actions model explicit
- catch structural mistakes earlier
- make large workflows easier to compose and review
- preserve committed YAML as a normal repository artifact
That constraint matters.
I did not want a magical DSL that hides the underlying workflow model so aggressively that users stop knowing what GitHub Actions will actually do.
So ghawb stays close to the platform:
- triggers are still triggers
- jobs are still jobs
- reusable workflows are still reusable workflows
- rendered output is still plain GitHub Actions YAML
It is not a replacement for understanding GitHub Actions.
It is a better place to author GitHub Actions.
What kind of mistakes it catches
This is where the library becomes useful in day-to-day work.
ghawb validates things like:
- identifier format for workflow IDs and job IDs
- invalid trigger/filter combinations
- duplicate or malformed step IDs
- references to undeclared step outputs in job outputs
- unsupported fields on reusable workflow jobs
- invalid matrix declarations
- invalid environment/config shapes
For example, if a job output references a step output that was never declared, that should not be something you discover only after pushing a branch.
If a reusable workflow job includes a field GitHub does not allow in that context, that should be rejected while the workflow is still being built.
If a matrix definition has the wrong shape, that should be treated as an authoring error, not a CI surprise.
So instead of:
CI failed 3 minutes later because the workflow shape was invalid
You get:
this workflow definition is invalid while you are still authoring it
That is a much better loop.
Why TypeScript
The value is not just type safety in the abstract.
The value is that workflow definitions become regular software artifacts.
That means you can:
- factor repeated workflow logic into named helpers
- test workflow construction
- inject render-time config
- use editor completion and refactoring
- keep reusable CI paths small and explicit
- review generated YAML without manually maintaining all of it
A growing YAML file is hard to refactor safely.
A TypeScript module can be composed, tested, and reviewed with the same tools you already use for application code.
How it differs from heavier CI abstractions
ghawb is intentionally not trying to be a new CI system.
It does not replace GitHub Actions with a different execution model.
It does not try to hide the workflow schema behind a completely separate abstraction.
It is closer to a typed authoring and rendering layer:
- TypeScript is the source
- GitHub Actions YAML is the committed artifact
- GitHub Actions remains the runtime
- the rendered output stays reviewable
That distinction is important.
Some tools give you a more powerful abstraction over builds or pipelines. That can be useful, but it also creates a larger conceptual boundary between the code you write and the workflow GitHub actually runs.
ghawb is deliberately smaller than that.
Its job is to make GitHub Actions authoring safer and more composable without making the resulting workflow feel mysterious.
Package boundaries
I wanted the core package to stay narrow.
The main package, @ghawb/sdk, is for:
- workflow builders
- expressions
- rendering payloads
- validation
More optional or opinionated parts live in separate packages:
-
@ghawb/job-helpersfor higher-level helpers like Node CI setup -
@ghawb/typed-actionsfor typed wrappers around common actions -
@ghawb/composite-actionsfor authoringaction.yml -
@ghawb/clifor rendering source modules into YAML -
@ghawb/reusable-workflow-importfor bringing existing reusable workflow YAML into the typed world without pushing YAML parsing into the SDK core
That split was intentional.
Users can start with a small, explicit core and opt into the more opinionated layers only when they help.
Example: a reusable, typed CI path
This is the sort of thing I wanted to make boring:
import { createJobId, createWorkflowId, defineWorkflow } from "@ghawb/sdk";
import { nodeCi } from "@ghawb/job-helpers";
export default defineWorkflow({
id: createWorkflowId("ci"),
name: "CI",
})
.onPush({ branches: ["main"] })
.onPullRequest({ branches: ["main"] })
.concurrency({
group: "ci-${{ github.ref }}",
cancelInProgress: true,
})
.addJob(createJobId("check"), (job) => {
job
.runsOn("ubuntu-latest")
.permissions({ contents: "read" })
.apply(nodeCi({ nodeVersion: "24" }));
})
.build();
This is not flashy.
That is the point.
CI should not be a place where I spend attention on preventable formatting and shape mistakes.
Why JSR-only distribution
This project ships through JSR only.
That decision comes from what the project actually is:
- source-first TypeScript packages
- Bun as the default development runtime
- continued Deno support
The current compatibility policy is:
- Bun 1.x
- Deno 2.x
That is a deliberately smaller promise surface than “every JavaScript runtime forever.”
For this project, JSR fits better than carrying extra packaging complexity just to preserve compatibility expectations that were not helping the product.
Getting started
The repository is here:
GitHub: https://github.com/moriturus/ghawb.ts
Start with:
bunx jsr add @ghawb/sdk
If you want the CLI too:
bunx jsr add @ghawb/cli
Today, that install path does not create a local ghawb executable because JSR's npm compatibility tarballs currently drop package.json#bin.
The working invocation is:
bun x @ghawb/cli render --input workflows/ci.ts
And if you are on Deno:
deno add jsr:@ghawb/sdk
The project README includes the current package layout and examples for:
- workflow authoring
- CLI rendering
- typed action wrappers
- composite action authoring
- reusable workflow import
Closing thought
If you have been treating GitHub Actions YAML as “just one of those things you have to suffer through,” I think there is real value in moving the authoring experience into typed code while still keeping the final YAML explicit and reviewable.
The goal is not to make GitHub Actions disappear.
The goal is to make preventable workflow mistakes show up earlier, closer to where they are created.
Top comments (0)