Tools: Complete Guide to A type-safe GitHub Actions workflow library in TypeScript

Tools: Complete Guide to A type-safe GitHub Actions workflow library in TypeScript

Prologue

The problem I wanted to fix

What ghawb looks like

The goal is not to hide GitHub Actions

What kind of mistakes it catches

Why TypeScript

How it differs from heavier CI abstractions

Package boundaries

Example: a reusable, typed CI path

Why JSR-only distribution

Getting started

Closing thought 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. 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: 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. GitHub Actions YAML has an awkward failure mode. A lot of mistakes are not conceptually complicated, but they are still easy to make: 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. Here is a small CI workflow: Then render it with the CLI: 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. I was not trying to pretend GitHub Actions is something other than GitHub Actions. The goal is narrower: 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: It is not a replacement for understanding GitHub Actions. It is a better place to author GitHub Actions. This is where the library becomes useful in day-to-day work. ghawb validates things like: 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. CI failed 3 minutes later because the workflow shape was invalid this workflow definition is invalid while you are still authoring it That is a much better loop. The value is not just type safety in the abstract. The value is that workflow definitions become regular software artifacts. 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. 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: 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. I wanted the core package to stay narrow. The main package, @ghawb/sdk, is for: More optional or opinionated parts live in separate packages: That split was intentional. Users can start with a small, explicit core and opt into the more opinionated layers only when they help. This is the sort of thing I wanted to make boring: CI should not be a place where I spend attention on preventable formatting and shape mistakes. This project ships through JSR only. That decision comes from what the project actually is: The current compatibility policy is: 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. The repository is here: GitHub: https://github.com/moriturus/ghawb.ts If you want the CLI too: 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: And if you are on Deno: The project README includes the current package layout and examples for: 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. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse

Command

Copy

$ 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(); 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(); 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(); bun x @ghawb/cli render --input workflows/ci.ts --output .github/workflows/ci.yml bun x @ghawb/cli render --input workflows/ci.ts --output .github/workflows/ci.yml bun x @ghawb/cli render --input workflows/ci.ts --output .github/workflows/ci.yml 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(); 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(); 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(); bunx jsr add @ghawb/sdk bunx jsr add @ghawb/sdk bunx jsr add @ghawb/sdk bunx jsr add @ghawb/cli bunx jsr add @ghawb/cli bunx jsr add @ghawb/cli bun x @ghawb/cli render --input workflows/ci.ts bun x @ghawb/cli render --input workflows/ci.ts bun x @ghawb/cli render --input workflows/ci.ts deno add jsr:@ghawb/sdk deno add jsr:@ghawb/sdk deno add jsr:@ghawb/sdk - type-safe builders - validation at construction time - deterministic YAML rendering - source-first distribution through JSR - 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 - 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 - triggers are still triggers - jobs are still jobs - reusable workflows are still reusable workflows - rendered output is still plain GitHub Actions YAML - 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 - 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 - TypeScript is the source - GitHub Actions YAML is the committed artifact - GitHub Actions remains the runtime - the rendered output stays reviewable - workflow builders - expressions - rendering payloads - @ghawb/job-helpers for higher-level helpers like Node CI setup - @ghawb/typed-actions for typed wrappers around common actions - @ghawb/composite-actions for authoring action.yml - @ghawb/cli for rendering source modules into YAML - @ghawb/reusable-workflow-import for bringing existing reusable workflow YAML into the typed world without pushing YAML parsing into the SDK core - source-first TypeScript packages - Bun as the default development runtime - continued Deno support - workflow authoring - CLI rendering - typed action wrappers - composite action authoring - reusable workflow import