Skip to content

moriturus/ghawb.ts

ghawb

Build GitHub Actions workflows and composite actions in TypeScript with full type safety, deterministic output, and source-first JSR distribution.

ghawb replaces hand-written YAML with fluent TypeScript builders that validate your workflow or composite action at construction time, catch mistakes before CI ever runs, and render deterministic YAML you can commit alongside your source.

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();

export default workflow;

Install

bunx jsr add @ghawb/sdk

For CLI rendering to YAML:

bunx jsr add @ghawb/cli

For the standard Node CI helper path:

bunx jsr add @ghawb/job-helpers

For opt-in typed wrappers around common first-party actions:

bunx jsr add @ghawb/typed-actions

For opt-in composite action authoring:

bunx jsr add @ghawb/composite-actions

For Deno projects, use native JSR specifiers:

deno add jsr:@ghawb/sdk

Runtime support: Bun 1.x, Deno 2.x. Packages are distributed through JSR only.

Choose The Right Package

Start with @ghawb/sdk. Add each opt-in package only when the previous one stops being enough.

Package Use it when Avoid it when
@ghawb/sdk You want typed workflow builders, validation, and deterministic render payloads. You only need a shell command to render an existing module and do not need to author workflows in code.
@ghawb/job-helpers You want the standard checkout/setup/install/test Node CI path or the narrower checkout/setup/install bootstrap used before release and publish steps. You need fully custom job steps or action-level control for most workflows.
@ghawb/typed-actions Repeated action refs like checkout, setup-node, cache, Pages, or artifacts are making raw with maps noisy or error-prone. You mostly use one-off actions whose input surface is too niche to justify a maintained wrapper.
@ghawb/cli You want a command-line path to render workflow or composite-action modules into committed YAML files. You are embedding rendering inside your own TypeScript process and do not need a CLI entrypoint.
@ghawb/reusable-workflow-import You need to call an existing reusable workflow YAML file from ghawb without rewriting that reusable workflow into builders immediately. You are already authoring the reusable workflow in @ghawb/sdk and can pass the builder or built definition directly to usesWorkflow().
@ghawb/composite-actions You want to author action.yml metadata with the same explicit builder style used for workflows. You only need workflow authoring; composite actions are a separate opt-in surface.

Recommended adoption path:

  1. Start with @ghawb/sdk for workflow builders and validation.
  2. Add @ghawb/job-helpers for Node CI or bootstrap prefixes without repeated boilerplate.
  3. Add @ghawb/typed-actions for typed with inputs on common actions.
  4. Add @ghawb/cli to render committed YAML from repository-local sources.
  5. Add @ghawb/reusable-workflow-import when you need to call existing reusable workflow YAML.

Quick Start

1. Define a workflow

Create a file (e.g. workflows/ci.ts):

import { createJobId, createWorkflowId, defineWorkflow } from "@ghawb/sdk";
import { nodeCi } from "@ghawb/job-helpers";

export default defineWorkflow({
  id: createWorkflowId("ci"),
  name: "CI",
})
  .onPush({ branches: ["main"] })
  .addJob(createJobId("build"), (job) => {
    job
      .runsOn("ubuntu-latest")
      .apply(nodeCi({ nodeVersion: "24" }))
      .run("bun run build", "Build");
  })
  .build();

2. Render to YAML

ghawb render --input workflows/ci.ts

You can inject per-target authoring config into the module at render time:

import { createJobId, createWorkflowId, defineWorkflow, getRenderConfig } from "@ghawb/sdk";

const config = getRenderConfig<{ onPushBranches?: string[] }>();

export default defineWorkflow({
  id: createWorkflowId("ci"),
  name: "CI",
})
  .onPush({ branches: config?.onPushBranches ?? ["main"] })
  .addJob(createJobId("build"), (job) => {
    job.runsOn("ubuntu-latest").run("bun test");
  })
  .build();
ghawb render \
  --input workflows/ci.ts \
  --config ci.render.json \
  --output .github/workflows/ci.yml

Supported injected config formats are JSON, YAML, and TOML. --config applies to the immediately preceding --input, so multi-target renders can pass different config files per module:

ghawb render \
  --input workflows/release.ts \
  --config release.json \
  --output .github/workflows/release.yml \
  --input workflows/hotfix.ts \
  --config hotfix.toml \
  --output .github/workflows/hotfix.yml

If you want to declare many render targets in one file, use --bulk:

ghawb render --bulk ghawb.render.json

3. Commit both files

Treat the .yml as generated output from your TypeScript source. For the supported repository-local path, keep workflow source modules under workflows/ and generated outputs under .github/workflows/.

4. Pick the next authoring path

  • Want the shortest path for standard Node CI or release bootstrap? Add @ghawb/job-helpers and use job.apply(nodeCi(options)) for the full CI path or job.apply(nodeBootstrap(options)) for the checkout/setup/install prefix.
  • Want typed with inputs for common actions? Add @ghawb/typed-actions.
  • Want a repository command that renders and checks committed YAML? Add @ghawb/cli.
  • Need to keep an existing reusable workflow YAML file in the flow? Add @ghawb/reusable-workflow-import.

Examples

CI with Concurrency

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();

Deployment with Environment

import { createJobId, createWorkflowId, defineWorkflow } from "@ghawb/sdk";

export default defineWorkflow({
  id: createWorkflowId("deploy"),
  name: "Deploy",
})
  .onPush({ branches: ["main"] })
  .addJob(createJobId("deploy"), (job) => {
    job
      .runsOn("ubuntu-latest")
      .environment({ name: "production", url: "https://example.com" })
      .permissions({ contents: "read", deployments: "write" })
      .uses("actions/checkout@v6")
      .run("bun install --frozen-lockfile")
      .run("bun run build")
      .run("bun run deploy");
  })
  .build();

Matrix Build

import { createJobId, createWorkflowId, defineWorkflow } from "@ghawb/sdk";

export default defineWorkflow({
  id: createWorkflowId("matrix"),
  name: "Matrix CI",
})
  .onPush({ branches: ["main"] })
  .addJob(createJobId("test"), (job) => {
    job
      .runsOn("ubuntu-latest")
      .strategyMatrix({
        bun: ["1.2", "1.3"],
        os: ["ubuntu-latest", "windows-latest"],
      })
      .uses("actions/checkout@v6")
      .uses("oven-sh/setup-bun@v2", {
        with: { "bun-version": "${{ matrix.bun }}" },
      })
      .run("bun install --frozen-lockfile")
      .run("bun test");
  })
  .build();

Typed Action Wrappers

import { createJobId, createWorkflowId, defineWorkflow } from "@ghawb/sdk";
import { actionsCache, actionsCheckout, actionsUploadArtifact } from "@ghawb/typed-actions";

export default defineWorkflow({
  id: createWorkflowId("typed-actions"),
  name: "Typed Actions",
})
  .onPush({ branches: ["main"] })
  .addJob(createJobId("build"), (job) => {
    job
      .runsOn("ubuntu-latest")
      .uses(actionsCheckout({ fetchDepth: 0 }), "Checkout")
      .uses("oven-sh/setup-bun@v2", "Setup Bun")
      .uses(
        actionsCache({
          path: "~/.bun/install/cache",
          key: "bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}",
          restoreKeys: "bun-${{ runner.os }}-",
        }),
        "Cache Store"
      )
      .run("bun install --frozen-lockfile")
      .run("bun test")
      .uses(actionsUploadArtifact({ name: "coverage", path: "coverage" }), "Upload Coverage");
  })
  .build();

Use @ghawb/typed-actions when you want autocomplete and typed with inputs for stable, common actions. Each wrapper defaults to the package's current pinned major and also accepts an optional second argument such as actionsCheckout({}, { version: "v5" }) when you need a different ref. Use raw .uses("owner/repo@ref", { with: ... }) for one-off actions that do not justify a wrapper, and prefer job.apply(nodeCi(...)) from @ghawb/job-helpers when the default Node CI sequence is sufficient without action-level customization, or job.apply(nodeBootstrap(...)) when you only need the checkout/setup/install prefix before custom release steps. Existing nodeCi(job, options) calls remain supported as a migration path.

Reusable Workflow

import { createJobId, createWorkflowId, defineWorkflow } from "@ghawb/sdk";

export default defineWorkflow({
  id: createWorkflowId("release"),
  name: "Release",
})
  .onPush({ tags: ["v*"] })
  .addJob(createJobId("publish"), (job) => {
    job
      .permissions({ contents: "read", packages: "write" })
      .usesWorkflow("octo-org/shared-workflows/.github/workflows/publish.yml@main", {
        with: { artifact: "dist" },
        outputs: ["artifact_url"],
        secrets: "inherit",
      });
  })
  .build();

CLI Usage

The @ghawb/cli package provides the ghawb command.

# Render a single workflow
ghawb render --input workflows/ci.ts

# Render a composite action definition
ghawb render --input actions/setup-bun.ts --output actions/setup-bun/action.yml

# Render multiple workflows in one pass
ghawb render \
  --input workflows/ci.ts      --output .github/workflows/ci.yml \
  --input workflows/deploy.ts  --output .github/workflows/deploy.yml

# Render with per-target config injection
ghawb render \
  --input workflows/ci.ts \
  --config ci.render.json \
  --output .github/workflows/ci.yml

The CLI dynamically imports your TypeScript module and renders it to YAML using the bundled YAML adapter. render auto-detects workflow or composite-action modules, validates the default export shape for the selected artifact type, and for the supported repository-local workflow path workflows/<name>.ts infers .github/workflows/<name>.yml when --output is omitted. When multiple explicit --input / --output pairs are provided, render processes each pair in order.

Use --bulk for render-plan manifests in JSON, YAML, or TOML. Use --config immediately after the corresponding --input when you want per-target render-time config injection; the injected value is exposed inside the workflow module through getRenderConfig<T>().

Supported Features

The SDK covers the majority of the GitHub Actions workflow syntax:

  • Triggers: push, pull_request, pull_request_target, workflow_dispatch, workflow_call, workflow_run, schedule, branch_protection_rule, and 20+ simple event types with activity-type filtering
  • Jobs: step-based and reusable-workflow jobs with needs dependency validation
  • Steps: run (inline commands), uses (action references), script file references with optional expand mode
  • Strategy: matrix with include/exclude, fail-fast, max-parallel
  • Permissions: granular per-key maps and read-all / write-all shorthand, at workflow and job level
  • Environment: named environments with optional URL, per-job and per-step env maps
  • Concurrency: group-based with optional cancel-in-progress
  • Container & Services: image, credentials, ports, volumes, Docker options
  • Defaults: defaults.run for shell and working-directory
  • Step metadata: id, if, name, shell, working-directory, with, env, continue-on-error, timeout-minutes
  • Typed helpers: actionRef() / workflowRef() for validated references, RunnerLabel constants for standard runners
  • Typed action core: typedActionStep() plus TypedActionStep for typed uses objects in the SDK
  • Opt-in typed action wrappers: @ghawb/typed-actions exports typed wrappers for common first-party actions including checkout, cache, setup-node, setup-python, setup-go, setup-java, setup-dotnet, github-script, Pages deployment actions, labeler, and artifact upload/download
  • Opt-in composite actions: @ghawb/composite-actions exports defineCompositeAction() and renders through the canonical ghawb render path for the first composite-action slice (name, description, inputs, outputs, and ordered composite runs.steps)
  • Expression helpers: expr(), context accessors (github, env, secrets, matrix, inputs, steps, needs), status-check functions (success, failure, always, cancelled), and comparison/logical helpers (literal, eq, ne, gt, gte, lt, lte, and, or, not) for type-safe ${{ }} construction
  • Identifiers: branded WorkflowId and JobId types with format validation

For the full support matrix, see docs/SYNTAX_COVERAGE.md.

Still Limited Or Unsupported

  • Composite actions currently support only the Sprint 20 initial slice: name, optional description, optional inputs, optional outputs, and composite runs.steps using run/uses plus name, id, if, env, with, shell, and working-directory
  • A few GitHub App-only or deprecated webhook events are not modeled as workflow triggers (deployment_protection_rule, installation*, classic project*)

Validation

The SDK validates workflows at build() time. If your workflow has structural issues, you get a WorkflowValidationError with an array of diagnostic issues — no CI round-trip required.

Validated constraints include: required triggers, non-empty job lists, step presence, identifier format, needs referential integrity, outputs step-reference validation, duplicate step IDs, permission-level correctness, cron format, matrix axis rules, double-shell rejection on script references, and more.

Complementary YAML Validation with actionlint

The SDK catches structural and type-level problems at construction time, but it does not validate the rendered YAML against GitHub's full runtime semantics. For YAML-level static analysis of generated workflow files, use the built-in CLI bridge:

# After rendering, verify the generated YAML with actionlint
ghawb render --input workflows/ci.ts
ghawb lint .github/workflows/ci.yml

# Render and lint in one step
ghawb render --input workflows/ci.ts --lint

# Lint multiple files
ghawb lint .github/workflows/*.yml

If actionlint is not installed, the CLI will exit with a clear message and installation instructions. You can also invoke actionlint directly:

actionlint .github/workflows/ci.yml

Using ghawb and actionlint together gives you type-safe construction and YAML-level validation.

For maintained docs and examples, run the repository-owned drift guardrail:

bun run verify:docs

Architecture

packages/
├── shared/   Branded identifiers (WorkflowId, JobId) and shared validation errors
├── sdk/      Workflow model, fluent builders, validation, deterministic renderer
├── job-helpers/       Opt-in high-level helpers such as `nodeCi()` and `nodeBootstrap()`
├── composite-actions/  Opt-in composite action builder, validation, and renderer
├── typed-actions/      Opt-in typed wrappers for common action refs
├── reusable-workflow-import/        Opt-in reusable workflow import
└── cli/                CLI entrypoint, argument parsing, YAML adapter (yaml library)
  • Pure TypeScript — no code generation, no macros, no build plugins.
  • JSR-only distribution with Bun as the default runtime and Deno compatibility retained.
  • Deterministic rendering — the same builder input always produces the same YAML output.
  • Pluggable emission — the renderer produces a structured payload; YAML serialization is injected at the CLI edge.

Coverage

This project maintains 100% SDK line, statement, and function coverage as measured by Vitest's v8 provider over packages/sdk/src/, with a 98% branch threshold. This covers the workflow model, builders, validation, and renderer.

Coverage does not extend to the CLI package, shared utilities, or workflow source files. In this project, "100% coverage" refers to the primary SDK coverage bar over packages/sdk/src/, while branch coverage intentionally uses a slightly lower floor for a small set of low-value branches.

Contributing

See docs/CONTRIBUTING.md for the contributor verification flow and workflow authoring conventions.

Verification Commands

bun run verify:pre-push    # Full pre-push verification (recommended)
bun run verify:workflows   # Workflow guardrail checks only
bun run check              # Format + lint + typecheck + tests
bun run coverage           # SDK line coverage with lcov output
bun run test               # Vitest + Deno tests
bun run generate:workflows # Re-render all workflow sources

Workflow Authoring Convention

Author committed workflow source modules inside the repository under workflows/. Treat .github/workflows/*.yml as generated output. Render every committed workflow module with bun run generate:workflows after changes, and commit the updated YAML alongside the source.

Documentation

License

Apache-2.0

About

A type-safe GitHub Actions workflow library in TypeScript

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Contributors