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 can run standalone. You can execute them directly with any TypeScript runtime (tsx, ts-node, Deno) to output the workflow JSON. Convenient for debugging and inspection.

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
ts
import { 
getAction
,
Job
,
Workflow
} from "../generated/index.js";
// 1. Import actions const
checkout
=
getAction
("actions/checkout@v5");
// 2. Create workflow with jobs and steps new
Workflow
({
name
: "CI",
on
: {
push
: {
branches
: ["main"] } },
}) .
jobs
(
j
=>
j
.
add
("build",
new
Job
("ubuntu-latest")
.
steps
(
s
=>
s
.
add
(
checkout
({}))
) ) ) .
build
("ci");

Using Actions

Run gaji dev

bash
gaji dev --watch

Importing Actions

Import actions using getAction():

typescript
const checkout = getAction("actions/checkout@v5");
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 are added via the .steps() callback with .add():

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

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

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

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

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

Creating Workflows

Basic Workflow

typescript
new Workflow({
  name: "CI",
  on: {
    push: {
      branches: ["main"],
    },
  },
})
  .jobs(j => j
    .add("build", buildJob)
  )
  .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")
  .steps(s => s
    .add(checkout({}))
    .add({ run: "npm test" })
  );

const build = new Job("ubuntu-latest")
  .steps(s => s
    .add(checkout({}))
    .add({ run: "npm run build" })
  );

new Workflow({
  name: "CI",
  on: { push: { branches: ["main"] } },
})
  .jobs(j => j
    .add("test", test)
    .add("build", build)
  )
  .build("ci");

Job Dependencies

Use needs in the JobConfig constructor parameter:

typescript
const test = new Job("ubuntu-latest")
  .steps(s => s
    .add({ run: "npm test" })
  );

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

new Workflow({
  name: "Deploy",
  on: { push: { branches: ["main"] } },
})
  .jobs(j => j
    .add("test", test)
    .add("deploy", deploy)
  )
  .build("deploy");

Matrix Builds

Use strategy in the JobConfig constructor:

typescript
const test = new Job("${{ matrix.os }}", {
  strategy: {
    matrix: {
      os: ["ubuntu-latest", "macos-latest", "windows-latest"],
      node: ["18", "20", "22"],
    },
  },
})

For a complete matrix build example with generated YAML, see Matrix Build Example.

Composite Actions

Create reusable composite actions using Action. Define inputs, add steps via .steps(), and call .build() to generate action.yml in your repository.

For a complete example, see Composite Action Example. For the full API, see Action.

Job Inheritance

Extend Job to create reusable, parameterized job templates:

ts
import { 
Job
,
getAction
,
Workflow
} from "../generated/index.js";
const
checkout
=
getAction
("actions/checkout@v5");
const
setupNode
=
getAction
("actions/setup-node@v4");
class
NodeTestJob
extends
Job
{
constructor(
nodeVersion
: string) {
super("ubuntu-latest"); this.
steps
(
s
=>
s
.
add
(
checkout
({}))
.
add
(
setupNode
({
with
: { "node-version":
nodeVersion
} }))
.
add
({
run
: "npm ci" })
.
add
({
run
: "npm test" })
); } }

For the full API reference and advanced patterns (e.g., DeployJob), see Job Inheritance.

Full Example: Per-environment Deploy with WorkflowCall

A pattern where you create a reusable workflow (workflow_call) and call it per environment with WorkflowCall.

First, write a reusable workflow containing the deploy steps. It receives the environment name via workflow_call inputs.

ts
import { 
getAction
,
Job
,
Workflow
} from "../generated/index.js";
const
checkout
=
getAction
("actions/checkout@v5");
const
setupNode
=
getAction
("actions/setup-node@v4");
new
Workflow
({
name
: "Publish",
on
: {
workflow_call
: {
inputs
: {
environment
: {
description
: "Target environment (alpha, staging, live)",
required
: true,
type
: "choice",
options
: ["alpha", "staging", "live"],
}, },
secrets
: {
DEPLOY_TOKEN
: {
required
: true },
}, }, }, }) .
jobs
(
j
=>
j
.
add
("publish",
new
Job
("ubuntu-latest")
.
steps
(
s
=>
s
.
add
(
checkout
({
name
: "Checkout" }))
.
add
(
setupNode
({
name
: "Setup Node.js",
with
: { "node-version": "20",
cache
: "npm" },
})) .
add
({
name
: "Install dependencies",
run
: "npm ci" })
.
add
({
name
: "Build",
run
: "npm run build" })
.
add
({
name
: "Publish",
run
: "npm run publish:${{ inputs.environment }}",
env
: {
DEPLOY_TOKEN
: "${{ secrets.DEPLOY_TOKEN }}",
}, }) ) ) ) .
build
("publish");

Next, use WorkflowCall to call this workflow for each environment. Use needs to enforce the order alpha → staging → live:

ts
import { 
WorkflowCall
,
Workflow
} from "../generated/index.js";
const
alpha
= new
WorkflowCall
("./.github/workflows/publish.yml", {
with
: {
environment
: "alpha" },
secrets
: "inherit",
}); const
staging
= new
WorkflowCall
("./.github/workflows/publish.yml", {
with
: {
environment
: "staging" },
secrets
: "inherit",
needs
: ["publish-alpha"],
}); const
live
= new
WorkflowCall
("./.github/workflows/publish.yml", {
with
: {
environment
: "live" },
secrets
: "inherit",
needs
: ["publish-staging"],
}); new
Workflow
({
name
: "Release",
on
: {
push
: {
tags
: ["v*"] } },
}) .
jobs
(
j
=>
j
.
add
("publish-alpha",
alpha
)
.
add
("publish-staging",
staging
)
.
add
("publish-live",
live
)
) .
build
("release");

The benefit of this structure is that deploy logic lives in publish.yml alone. When you need to change deploy steps, just update publish.ts and it applies to all environments.

Docker Actions

Create Docker container actions using DockerAction. Specify a Dockerfile or a Docker Hub image with the docker:// prefix.

See DockerAction API for the full API and examples.

Environment Variables

Workflow-level

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

Job-level

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

Step-level

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

Outputs

Typed Step Outputs

When you provide an id to an action step, gaji returns an ActionStep with typed output properties:

typescript
const checkout = getAction("actions/checkout@v5");

// Providing id gives typed outputs
const step = checkout({ id: "my-checkout" });
step.outputs.ref     // "${{ steps.my-checkout.outputs.ref }}"
step.outputs.commit  // "${{ steps.my-checkout.outputs.commit }}"

Passing Outputs Between Jobs

The primary pattern uses the .jobs() callback, where job output context flows automatically:

typescript
const checkout = getAction("actions/checkout@v5");

new Workflow({ name: "CI", on: { push: {} } })
  .jobs(j => j
    .add("build",
      new Job("ubuntu-latest")
        .steps(s => s
          .add(checkout({ id: "co" }))
        )
        .outputs(output => ({ ref: output.co.ref }))
    )
    .add("deploy", output =>
      new Job("ubuntu-latest", { needs: ["build"] })
        .steps(s => s
          .add({ run: "echo " + output.build.ref })
        )
    )
  )
  .build("ci");

The output parameter in the deploy callback provides typed access to the build job's declared outputs, generating ${{ needs.build.outputs.ref }} expressions.

You can also use jobOutputs() as a compatibility helper when defining jobs as separate variables:

typescript
const build = new Job("ubuntu-latest")
  .steps(s => s.add(checkout({ id: "co" })))
  .outputs(output => ({ ref: output.co.ref }));

const buildOutputs = jobOutputs("build", build);
// buildOutputs.ref → "${{ needs.build.outputs.ref }}"

For manually defined outputs (e.g., from run steps that write to $GITHUB_OUTPUT):

typescript
new Workflow({ name: "CI", on: { push: { tags: ["v*"] } } })
  .jobs(j => j
    .add("setup",
      new Job("ubuntu-latest")
        .steps(s => s
          .add({ id: "version", run: 'echo "value=1.0.0" >> $GITHUB_OUTPUT' })
        )
        .outputs({
          version: "${{ steps.version.outputs.value }}",
        })
    )
    .add("deploy", output =>
      new Job("ubuntu-latest", { needs: ["setup"] })
        .steps(s => s
          .add({ run: "deploy --version " + output.setup.version })
        )
    )
  )
  .build("ci");

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

gaji catches typos in action input keys and wrong value types at compile time. See Type Safety for examples.

Known Limitations

getAction() Requires String Literals

gaji statically analyzes your TypeScript files to extract action references without executing them. This means getAction() only works with string literals:

typescript
// ✅ Works - string literal
const checkout = getAction("actions/checkout@v5");

// ❌ Does NOT work - variable reference
const ref = "actions/checkout@v5";
const checkout = getAction(ref);

// ❌ Does NOT work - template literal
const checkout = getAction(`actions/checkout@v${version}`);

// ❌ Does NOT work - object property
const checkout = getAction(config.checkoutRef);

If gaji cannot detect the action reference, it won't fetch the action.yml or generate types for that action. Always pass the full owner/repo@version string directly.

String Escaping in YAML Output

Since gaji converts JavaScript strings to YAML, characters that are already escaped in JavaScript may be double-escaped in the output. For example:

typescript
// In TypeScript, \n is a newline character
.add({ run: "echo \"hello\nworld\"" })

The JS string contains a literal newline, which YAML will handle correctly. However, if you actually want the literal \n characters in the YAML output (e.g., for multiline echo), you need to double-escape:

typescript
// Double-escape to preserve the literal \n in YAML
.add({ run: "echo hello\\nworld" })

Tip: For multi-line commands, prefer template literals instead of escape sequences:

typescript
.add({
  run: `
    echo hello
    echo world
  `.trim(),
})

Next Steps

Released under the MIT License.