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:
- Actions: Imported using
getAction() - Jobs: Created using the
Jobclass - Workflows: Created using the
Workflowclass
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:
gaji add actions/checkout@v4Importing Actions
Import actions using getAction():
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:
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:
const job = new Job("ubuntu-latest");Supported Runners
// 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():
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
const workflow = new Workflow({
name: "CI",
on: {
push: {
branches: ["main"],
},
},
}).addJob("build", buildJob);
workflow.build("ci");Trigger Events
Push
on: {
push: {
branches: ["main", "develop"],
tags: ["v*"],
paths: ["src/**", "tests/**"],
},
}Pull Request
on: {
pull_request: {
branches: ["main"],
types: ["opened", "synchronize", "reopened"],
},
}Schedule (Cron)
on: {
schedule: [
{ cron: "0 0 * * *" }, // Daily at midnight
],
}Multiple Triggers
on: {
push: { branches: ["main"] },
pull_request: { branches: ["main"] },
workflow_dispatch: {}, // Manual trigger
}Multiple Jobs
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:
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:
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:
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:
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
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
const workflow = new Workflow({
name: "CI",
on: { push: { branches: ["main"] } },
env: {
NODE_ENV: "production",
},
});Job-level
const job = new Job("ubuntu-latest")
.env({
DATABASE_URL: "${{ secrets.DATABASE_URL }}",
});Step-level
.addStep({
run: "npm run deploy",
env: {
API_KEY: "${{ secrets.API_KEY }}",
},
})Outputs
Define and use job outputs:
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:
// ❌ 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
- Learn about Configuration
- See Examples
- Check the API Reference
