# Writing Workflows
This guide explains how to write type-safe GitHub Actions workflows using gaji.
::: tip 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
```ts twoslash
// @filename: workflows/example.ts
// ---cut---
import { getAction, Job, Workflow } from "../generated/index.js";
// 1. Import actions
const checkout = getAction("actions/checkout@v5");
// 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@v5
```
### 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 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:
```ts twoslash
// @filename: workflows/example.ts
// ---cut---
import { CompositeAction, getAction } from "../generated/index.js";
const checkout = getAction("actions/checkout@v5");
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`:
```ts twoslash
// @filename: workflows/example.ts
// ---cut---
import { CompositeJob, getAction, Workflow } from "../generated/index.js";
const checkout = getAction("actions/checkout@v5");
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 {
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.
## 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
.addStep({ 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
.addStep({ run: "echo hello\\nworld" })
```
**Tip**: For multi-line commands, prefer template literals instead of escape sequences:
```typescript
.addStep({
run: `
echo hello
echo world
`.trim(),
})
```
## Next Steps
- Learn about [Configuration](./configuration.md)
- See [Examples](/examples/simple-ci)
- Check the [API Reference](/reference/api)