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:
- 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@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
gaji dev --watchImporting Actions
Import actions using getAction():
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:
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 are added via the .steps() callback with .add():
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
new Workflow({
name: "CI",
on: {
push: {
branches: ["main"],
},
},
})
.jobs(j => j
.add("build", buildJob)
)
.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")
.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:
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:
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:
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.
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:
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
new Workflow({
name: "CI",
on: { push: { branches: ["main"] } },
env: {
NODE_ENV: "production",
},
});Job-level
new Job("ubuntu-latest", {
env: {
DATABASE_URL: "${{ secrets.DATABASE_URL }}",
},
});Step-level
.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:
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:
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:
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):
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:
// ✅ 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:
// 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:
// 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:
.add({
run: `
echo hello
echo world
`.trim(),
})Next Steps
- Learn about Configuration
- See Examples
- Check the API Reference
