Why I made gaji - TypeScript DSL for GitHub Actions with auto codegen

개발곰 @gaebalgom@hackers.pub

Recently I built a tool for writing GitHub Actions in TypeScript. Its name: GitHub Actions Justified Improvements, or gaji. Why did I end up writing GitHub Actions in TS, and how does it differ from existing tools? Let's dig in.

gaji official docs

Interning at Toss Client DevOps Team

Starting January this year, I began an internship at the Toss Client DevOps Team. The simplest way to describe the team: they build the infrastructure that lets client developers deploy quickly and safely.

My main work involved migrating existing workflows to GitHub Actions and creating custom actions for new checks. After dealing with dozens of workflows, I noticed something ironic: a team whose job is to build fast and safe deployment infrastructure was itself stuck with a slow and fragile process for building that infrastructure. To catch a single typo, I had to go through commit → push → CI run → check failure, over and over. There was no way to reproduce things locally, so mostly I just got better at git.

Ideas That Stuck With Me

A few ideas crystallized during the internship. Not a philosophy or anything—just observations.

  1. Software is better when its inputs and outputs are clear.

  2. YAML is not a language for expressing behavior. Actions have inputs, outputs, and side effects. They're behavior. Using YAML—a data description language—to express that seems like a category error. Trying to make something non-declarative look declarative is how you end up with the awkward pattern of shell scripts shoved inside YAML.

  3. Good tools should be reproducible in any environment.

gaji came out of points 1 and 2. Point 3 is the domain of tools like act.

Three Structural Problems with GitHub Actions

With the above in mind, here's what's wrong with GitHub Actions:

  1. YAML is a data description language. It's not suited for expressing behavior.
  2. There's no type checking. You depend on external repositories constantly (even actions/checkout@v5 is external), but there's zero validation of what inputs they expect. You're on your own reading docs and getting the format right by hand.
  3. Local reproduction is painful.

These three problems combine to make GitHub Actions a platform where you can't catch even a simple typo until you actually run it.

name: CI
on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-node@v4
        with:
          node-versoin: '20'  # Typo in the key name! No error until runtime ❌
          cache: 'npm'

      - run: npm ci
      - run: npm test

gaji focuses on solving the first and second problems.

How gaji Compares to Existing Tools

actionlint

Honestly, I didn't know actionlint existed when I started building gaji. I found out about it later, and it's a great tool. It does type checking on ${{ }} expressions, validates action inputs, integrates shellcheck—it catches a lot of errors in YAML workflows.

But the approach is fundamentally different. actionlint is a linter that keeps YAML and catches errors after the fact. gaji leaves YAML behind entirely and tries to make errors impossible at write time. A linter says "you made a mistake." A type system makes it hard to make the mistake in the first place. On the developer experience side, actionlint requires running a separate CLI or installing an editor plugin. With gaji, you get native TypeScript autocomplete and inline type hints in your editor right away.

Using both together is even better. Running actionlint on the YAML that gaji generates is the ideal combo. gaji handles type safety for action inputs on the TypeScript side, and actionlint fills in the gaps with ${{ }} expression validation and other YAML-level checks.

emmanuelnk/github-actions-workflow-ts

github-actions-workflow-ts was the project that gave me the idea of expressing GitHub Actions in TypeScript. The concept of auto-generating types from action.yml is the same as gaji. The difference is who does the codegen. In github-actions-workflow-ts, the maintainer manages a trackedActions list and publishes it as an npm package. In gaji, the user generates types locally for every action they reference, on the spot.

The advantages of github-actions-workflow-ts are clear: install the npm package and you're good to go. No separate codegen step for the user. It also supports type safety for step outputs.

The downsides: only actions on the maintainer's tracked list get type support, so custom actions or internal GHE actions are out of luck. Adding new actions or versions depends on the maintainer, and it requires an external JS runtime.

gaji's advantage is that it generates types for whatever action you reference, instantly. Custom actions, internal GHE actions, doesn't matter. It runs as a Rust binary, so no JS runtime needed either. The downside is that you have to run gaji dev to generate types, and since generation happens locally, you need to set it up per project.

I went with gaji's approach because I'd spent time in a GHE environment dealing with tons of custom actions, and I needed something that could handle that.

gaji's Approach

Why I Built It This Way

Why Rust? Speed—but not just runtime speed. The built-in tooling (clippy, rustfmt, etc.) made LLM-assisted development much faster, and that mattered because I was building this while still interning. Also, Rust-based TypeScript tooling like oxc is already mature, so working with TypeScript from Rust was surprisingly comfortable.

Why TypeScript? I'm a JS/TS developer, for one. TypeScript's type system is powerful and widely known—most developers already have some familiarity with it. And the YAML structure of GitHub Actions is basically JSON anyway, so expressing it as JSON-like objects in TS/JS feels very natural. A neat proof of this: every gaji workflow file is itself a valid TypeScript file. If you run a gaji workflow in a TS-native runtime like Deno, it prints the workflow as JSON.

Why auto-codegen from action.yml? At the Client DevOps Team, I was writing custom actions, and there were already a lot of them. Having to read through each one's documentation by hand was exhausting, and that frustration was the direct motivation. While contributing to Hackers.pub, I encountered the concept of GraphQL auto-codegen and realized the same approach could work for GitHub Actions.

Core Structure

A gaji workflow follows the flow getAction()JobWorkflow.build(). Running gaji dev --watch detects new action references and auto-generates types for them.

import { getAction, Job, Workflow } from "../generated/index.js";

const checkout = getAction("actions/checkout@v5");
const setupNode = getAction("actions/setup-node@v4");

const build = new Job("ubuntu-latest")
  .addStep(checkout({}))
  .addStep(setupNode({
    with: {
      "node-version": "20",
      cache: "npm",
    },
  }))
  .addStep({ run: "npm ci" })
  .addStep({ run: "npm test" });

const workflow = new Workflow({
  name: "CI",
  on: { push: { branches: ["main"] } },
}).addJob("build", build);

workflow.build("ci");

Write it this way and you get autocomplete for all action inputs, compile-time type checking, IDE hints pulled from action.yml docs, and default value display—all working out of the box. Extracting common logic into a CompositeJob class, or calling reusable workflows with CallJob, feels natural because it's just TypeScript code.

Real-World Example: gaji's Own Release CD

gaji's entire CI/CD is written with gaji. The most complex one, release.ts, has 4 jobs:

  • build: cross-compilation for 5 platforms (linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64)
  • upload-release-assets: upload binaries and checksums to a GitHub Release
  • publish-npm: publish per-platform packages to npm
  • publish-crates: OIDC-based publish to crates.io

When this workflow compiles to YAML, it comes out to about 180 lines of flat structure. Without comments, it's hard to tell where one job ends and the next begins, or how they depend on each other. In the TypeScript version, the variable names alone—build, uploadReleaseAssets, publishNpm, publishCrates—make the structure immediately obvious. Six external actions are used with type safety, and the complex matrix build with OS-specific branching reads clearly within the code structure.

gaji's Limitations

gaji still has limitations.

The output is still YAML, at the end of the day. As long as the GitHub Actions platform takes YAML as input, gaji is constrained by that. gaji is the best you can do within this platform, not an ideal solution. Because the original action.yml inputs are only described as strings or numbers, gaji can't provide fine-grained value-level types like "npm" | "yarn" | "pnpm". GitHub Actions expressions like ${{ matrix.target.rust_target }} are still plain strings with no type verification possible.

There are technical limitations too. gaji dev works by statically analyzing getAction() calls, so only string literals are supported—variables and template literals won't work. It also can't catch typos in string values themselves (cache: "npn" vs cache: "npm").

What's Next

gaji's current architecture is TypeScript → Parse (oxc) → Execute (QuickJS) → YAML. While building the codegen, I had an idea: if the frontend (the language users write in) and the backend (YAML generation) are properly separated with a well-defined intermediate language, workflows could be written in languages other than TypeScript.

For 1.0, I'm planning to introduce a plugin system that opens up support for other languages. I want gaji's core value—automatic type generation from action.yml—to not be limited to TypeScript alone.

Special Thanks

Thanks to kiwiyou and RanolP for suggesting the name, and sij411 for making the logo.

Thanks to the Client DevOps Team as well. Without the experiences I had on that team, I never would have given YAML and GitHub Actions this much thought.

Thanks also to emmanuelnk/github-actions-workflow-ts. The idea of expressing GitHub Actions in TypeScript and the basic TS API design came from there.

Read more →
0