Skip to content

Writing Workflows

This guide explains how to write type-safe GitHub Actions workflows using gaji.

Standalone TypeScript Files

Workflow files generated by gaji are completely standalone and self-contained. You can run them directly with any TypeScript runtime (tsx, ts-node, Deno) to output the workflow JSON. This makes debugging and inspection easy!

Basic Structure

A gaji workflow consists of three main components:

  1. Actions: Imported using getAction()
  2. Jobs: Created using the Job class
  3. Workflows: Created using the Workflow class
typescript
import { getAction, Job, Workflow } from "../generated/index.js";

// 1. Import actions
const checkout = getAction("actions/checkout@v4");

// 2. Create jobs
const build = new Job("ubuntu-latest")
  .addStep(checkout({}));

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

// 4. Build to YAML
workflow.build("ci");

Using Actions

Adding Actions

First, add the action and generate types:

bash
gaji add actions/checkout@v4

Importing Actions

Import actions using getAction():

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

Using Actions with Type Safety

Actions return a function that accepts configuration:

typescript
const step = checkout({
  name: "Checkout code",
  with: {
    // ✅ Full autocomplete for all inputs!
    repository: "owner/repo",
    ref: "main",
    token: "${{ secrets.GITHUB_TOKEN }}",
    "fetch-depth": 0,
  },
});

Your editor will provide:

  • ✅ Autocomplete for all action inputs
  • ✅ Type checking
  • ✅ Documentation from action.yml
  • ✅ Default values shown

Creating Jobs

Jobs are created using the Job class:

typescript
const job = new Job("ubuntu-latest");

Supported Runners

typescript
// Ubuntu
new Job("ubuntu-latest")
new Job("ubuntu-22.04")
new Job("ubuntu-20.04")

// macOS
new Job("macos-latest")
new Job("macos-13")
new Job("macos-12")

// Windows
new Job("windows-latest")
new Job("windows-2022")
new Job("windows-2019")

// Self-hosted
new Job("self-hosted")
new Job(["self-hosted", "linux", "x64"])

Adding Steps

Steps can be added using .addStep():

typescript
const job = new Job("ubuntu-latest")
  // Action step
  .addStep(checkout({
    name: "Checkout",
  }))

  // Run command
  .addStep({
    name: "Build",
    run: "npm run build",
  })

  // Multi-line command
  .addStep({
    name: "Install dependencies",
    run: `
      npm ci
      npm run build
      npm test
    `.trim(),
  })

  // With environment variables
  .addStep({
    name: "Deploy",
    run: "npm run deploy",
    env: {
      NODE_ENV: "production",
      API_KEY: "${{ secrets.API_KEY }}",
    },
  })

  // Conditional step
  .addStep({
    name: "Upload artifacts",
    if: "success()",
    run: "npm run upload",
  });

Creating Workflows

Basic Workflow

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

workflow.build("ci");

Trigger Events

Push

typescript
on: {
  push: {
    branches: ["main", "develop"],
    tags: ["v*"],
    paths: ["src/**", "tests/**"],
  },
}

Pull Request

typescript
on: {
  pull_request: {
    branches: ["main"],
    types: ["opened", "synchronize", "reopened"],
  },
}

Schedule (Cron)

typescript
on: {
  schedule: [
    { cron: "0 0 * * *" },  // Daily at midnight
  ],
}

Multiple Triggers

typescript
on: {
  push: { branches: ["main"] },
  pull_request: { branches: ["main"] },
  workflow_dispatch: {},  // Manual trigger
}

Multiple Jobs

typescript
const test = new Job("ubuntu-latest")
  .addStep(checkout({}))
  .addStep({ run: "npm test" });

const build = new Job("ubuntu-latest")
  .addStep(checkout({}))
  .addStep({ run: "npm run build" });

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

Job Dependencies

Use .needs() to create job dependencies:

typescript
const test = new Job("ubuntu-latest")
  .addStep({ run: "npm test" });

const deploy = new Job("ubuntu-latest")
  .needs(["test"])  // Wait for test job
  .addStep({ run: "npm run deploy" });

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

Matrix Builds

Create matrix builds for testing across multiple versions:

typescript
const test = new Job("${{ matrix.os }}")
  .strategy({
    matrix: {
      os: ["ubuntu-latest", "macos-latest", "windows-latest"],
      node: ["18", "20", "22"],
    },
  })
  .addStep(checkout({}))
  .addStep(setupNode({
    with: {
      "node-version": "${{ matrix.node }}",
    },
  }))
  .addStep({ run: "npm test" });

Composite Actions

Create reusable composite actions:

typescript
import { CompositeAction } from "../generated/index.js";

const myAction = new CompositeAction({
  name: "My Action",
  description: "Reusable action",
  inputs: {
    version: {
      description: "Version to install",
      required: true,
    },
  },
})
  .addStep(checkout({}))
  .addStep({
    run: "echo Installing version ${{ inputs.version }}",
  });

myAction.build("my-action");

This generates action.yml in your repository.

Composite Jobs

Create reusable job templates using CompositeJob:

typescript
import { CompositeJob, getAction } from "../generated/index.js";

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

// Define a reusable job class
class NodeTestJob extends CompositeJob {
  constructor(nodeVersion: string) {
    super("ubuntu-latest");

    this
      .addStep(checkout({
        name: "Checkout code",
      }))
      .addStep(setupNode({
        name: `Setup Node.js ${nodeVersion}`,
        with: {
          "node-version": nodeVersion,
          cache: "npm",
        },
      }))
      .addStep({
        name: "Install dependencies",
        run: "npm ci",
      })
      .addStep({
        name: "Run tests",
        run: "npm test",
      });
  }
}

// Use in workflow
const workflow = new Workflow({
  name: "Test Matrix",
  on: { push: { branches: ["main"] } },
})
  .addJob("test-node-18", new NodeTestJob("18"))
  .addJob("test-node-20", new NodeTestJob("20"))
  .addJob("test-node-22", new NodeTestJob("22"));

workflow.build("test-matrix");

Advanced Example: Parameterized Jobs

typescript
class DeployJob extends CompositeJob {
  constructor(environment: "staging" | "production") {
    super("ubuntu-latest");

    this
      .env({
        ENVIRONMENT: environment,
        API_URL: environment === "production"
          ? "https://api.example.com"
          : "https://staging.api.example.com",
      })
      .addStep(checkout({}))
      .addStep(setupNode({ with: { "node-version": "20" } }))
      .addStep({
        name: "Deploy",
        run: `npm run deploy:${environment}`,
        env: {
          DEPLOY_TOKEN: "${{ secrets.DEPLOY_TOKEN }}",
        },
      });
  }
}

// Use in workflow
const workflow = new Workflow({
  name: "Deploy",
  on: { push: { tags: ["v*"] } },
})
  .addJob("deploy-staging", new DeployJob("staging"))
  .addJob("deploy-production",
    new DeployJob("production").needs(["deploy-staging"])
  );

Benefits:

  • Reuse common job patterns
  • Type-safe parameters
  • Easier maintenance
  • Consistent job structure

Environment Variables

Workflow-level

typescript
const workflow = new Workflow({
  name: "CI",
  on: { push: { branches: ["main"] } },
  env: {
    NODE_ENV: "production",
  },
});

Job-level

typescript
const job = new Job("ubuntu-latest")
  .env({
    DATABASE_URL: "${{ secrets.DATABASE_URL }}",
  });

Step-level

typescript
.addStep({
  run: "npm run deploy",
  env: {
    API_KEY: "${{ secrets.API_KEY }}",
  },
})

Outputs

Define and use job outputs:

typescript
const build = new Job("ubuntu-latest")
  .outputs({
    version: "${{ steps.version.outputs.value }}",
  })
  .addStep({
    id: "version",
    run: 'echo "value=1.0.0" >> $GITHUB_OUTPUT',
  });

const deploy = new Job("ubuntu-latest")
  .needs(["build"])
  .addStep({
    run: "echo Deploying version ${{ needs.build.outputs.version }}",
  });

Tips

1. Use Watch Mode

Always use gaji dev --watch during development to automatically generate types for new actions.

2. Review Generated YAML

Always review the generated YAML before committing to ensure correctness.

3. Type Safety

Take advantage of TypeScript's type checking:

typescript
// ❌ Type error - unknown property key
setupNode({
  with: {
    "node-versoin": "20",  // Typo in key name! ❌
  },
});

// ❌ Type error - wrong type
setupNode({
  with: {
    "node-version": 20,  // Should be string! ❌
  },
});

// ✅ Correct
setupNode({
  with: {
    "node-version": "20",  // ✅ Correct key and type
    cache: "npm",
  },
});

Note: While gaji provides type safety for property keys and types, it cannot validate string values (e.g., cache: "npn" vs cache: "npm") at compile time. Always review generated YAML to catch such typos.

Next Steps

Released under the MIT License.