Skip to content

Migration ​

This guide helps you migrate existing YAML workflows to TypeScript with gaji.

Automatic Migration ​

gaji can automatically convert existing YAML workflows to TypeScript.

bash
gaji init --migrate

The process follows this order:

  1. Detect existing YAML workflows in .github/workflows/
  2. Convert them to TypeScript in workflows/
  3. Backup original YAML files (.yml.backup)
  4. Generate types for all actions used

Action Migration ​

gaji also migrates existing local actions (.github/actions/*/action.yml) to TypeScript.

bash
gaji init --migrate

This detects both workflows and actions automatically. Actions are converted to Action, NodeAction, or DockerAction classes depending on the runs.using field.

Composite Action ​

Before (.github/actions/setup-env/action.yml):

yaml
name: Setup Environment
description: Setup Node.js and install dependencies
inputs:
  node-version:
    description: Node.js version to use
    required: false
    default: "20"
outputs:
  cache-hit:
    description: Whether cache was hit
    value: ${{ steps.cache.outputs.cache-hit }}
runs:
  using: composite
  steps:
    - uses: actions/checkout@v5
    - name: Install dependencies
      run: npm ci
      shell: bash
    - name: Cache node_modules
      id: cache
      uses: actions/cache@v4
      with:
        path: node_modules
        key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}

After (workflows/action-setup-env.ts):

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

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

const action = new Action({
    name: "Setup Environment",
    description: "Setup Node.js and install dependencies",
    inputs: {
        "node-version": {
            description: "Node.js version to use",
            required: false,
            default: "20",
        },
    },
    outputs: {
        "cache-hit": {
            description: "Whether cache was hit",
            value: "${{ steps.cache.outputs.cache-hit }}",
        },
    },
});

action
    .steps(s => s
        .add(checkout({}))
        .add({
            name: "Install dependencies",
            run: "npm ci",
            shell: "bash",
        })
        .add(cache({
            id: "cache",
            name: "Cache node_modules",
            with: {
                path: "node_modules",
                key: "${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}",
            },
        }))
    )
    .build("setup-env");

JavaScript Action ​

Before (.github/actions/notify/action.yml):

yaml
name: Send Notification
description: Send a Slack notification
inputs:
  webhook-url:
    description: Slack webhook URL
    required: true
  message:
    description: Message to send
    required: true
runs:
  using: node20
  main: dist/index.js
  post: dist/cleanup.js

After (workflows/action-notify.ts):

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

const action = new NodeAction(
    {
        name: "Send Notification",
        description: "Send a Slack notification",
        inputs: {
            "webhook-url": {
                description: "Slack webhook URL",
                required: true,
            },
            message: {
                description: "Message to send",
                required: true,
            },
        },
    },
    {
        using: "node20",
        main: "dist/index.js",
        post: "dist/cleanup.js",
    },
);

action.build("notify");

Docker Action ​

Before (.github/actions/lint/action.yml):

yaml
name: Lint
description: Run linter in Docker
inputs:
  config:
    description: Config file path
    required: false
    default: ".lintrc"
runs:
  using: docker
  image: Dockerfile
  entrypoint: entrypoint.sh
  args:
    - --config
    - ${{ inputs.config }}

After (workflows/action-lint.ts):

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

const action = new DockerAction(
    {
        name: "Lint",
        description: "Run linter in Docker",
        inputs: {
            config: {
                description: "Config file path",
                required: false,
                default: ".lintrc",
            },
        },
    },
    {
        using: "docker",
        image: "Dockerfile",
        entrypoint: "entrypoint.sh",
        args: ["--config", "${{ inputs.config }}"],
    },
);

action.build("lint");

Supported Action Types ​

Typeruns.usingMigrated To
CompositecompositeAction
JavaScriptnode12, node16, node20NodeAction
DockerdockerDockerAction

Manual Migration ​

If you prefer to migrate manually, follow this process.

Step 1: Analyze Your YAML ​

Here is a simple YAML workflow as an example.

yaml
name: CI
on:
  push:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test

Step 2: Add Required Actions ​

bash
gaji add actions/checkout@v5
gaji add actions/setup-node@v4

Step 3: Convert to TypeScript ​

Create workflows/ci.ts:

ts
import { 
getAction
,
Job
,
Workflow
} from "../generated/index.js";
// Import actions const
checkout
=
getAction
("actions/checkout@v5");
const
setupNode
=
getAction
("actions/setup-node@v4");
// 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
({}))
.
add
(
setupNode
({
with
: { "node-version": "20" },
})) .
add
({
run
: "npm ci" })
.
add
({
run
: "npm test" })
) ) ) .
build
("ci");

Step 4: Build and Verify ​

bash
# Build TypeScript to YAML
gaji build

Step 5: Clean Up ​

Once verified, remove the backup:

bash
rm .github/workflows/ci.yml.backup

Common Migration Patterns ​

Multiple Jobs ​

YAML:

yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - run: npm test

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - run: npm run build

TypeScript:

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

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

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

Matrix Strategy ​

YAML:

yaml
jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        node: [18, 20, 22]

TypeScript:

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

Environment Variables ​

YAML:

yaml
env:
  NODE_ENV: production

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      API_KEY: ${{ secrets.API_KEY }}

TypeScript:

typescript
new Workflow({
  name: "Deploy",
  on: { push: { branches: ["main"] } },
  env: {
    NODE_ENV: "production",
  },
})
  .jobs(j => j
    .add("deploy",
      new Job("ubuntu-latest", {
        env: {
          API_KEY: "${{ secrets.API_KEY }}",
        },
      })
    )
  )
  .build("deploy");

Conditional Steps ​

YAML:

yaml
steps:
  - name: Deploy
    if: github.ref == 'refs/heads/main'
    run: npm run deploy

TypeScript:

typescript
.add({
  name: "Deploy",
  if: "github.ref == 'refs/heads/main'",
  run: "npm run deploy",
})

Job Outputs ​

YAML:

yaml
jobs:
  build:
    outputs:
      version: ${{ steps.version.outputs.value }}
    steps:
      - id: version
        run: echo "value=1.0.0" >> $GITHUB_OUTPUT

TypeScript:

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

Configuration Migration ​

If your project uses the older .gaji.toml configuration file, gaji can migrate it to gaji.config.ts automatically.

Automatic Migration ​

When you run gaji init in a project that has a .gaji.toml, gaji detects the file and offers to migrate it:

bash
gaji init
# Detected .gaji.toml configuration file.
# Migrate to gaji.config.ts? [y/N]

If you confirm, gaji will:

  1. Read .gaji.toml and generate a gaji.config.ts file with defineConfig()
  2. If .gaji.local.toml exists, generate a corresponding gaji.config.local.ts
  3. Remove the old TOML files after successful migration

Manual Migration ​

To migrate manually, create gaji.config.ts at your project root:

Before (.gaji.toml):

toml
workflows = "src/workflows"
output = ".github"

[build]
cache_ttl_days = 14

[github]
token = "ghp_xxx"

After (gaji.config.ts):

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

export default defineConfig({
  workflows: "src/workflows",
  build: {
    cacheTtlDays: 14,
  },
});

Note that TOML keys use snake_case while TypeScript config uses camelCase. Secrets like github.token should go in gaji.config.local.ts (which should be added to .gitignore):

typescript
// gaji.config.local.ts
import { defineConfig } from "./generated/index.js";

export default defineConfig({
  github: {
    token: "ghp_xxx",
  },
});

Migration Checklist ​

  1. Install gaji
  2. Initialize project
  3. Add all actions used in your workflows
  4. Convert YAML to TypeScript
  5. Build and verify generated YAML
  6. Test workflows in a branch
  7. Remove backup files
  8. Update documentation

Tips ​

1. Start Small ​

Migrate one workflow at a time, starting with the simplest one.

2. Use Automatic Migration ​

For complex workflows, let gaji do the initial conversion:

bash
gaji init --migrate

Then refine the generated TypeScript.

3. Test in a Branch ​

Always test migrated workflows in a feature branch before merging.

4. Keep Both During Transition ​

During migration, you can keep both YAML and TypeScript versions:

  • Backup old workflows with .backup extension (e.g., ci.yml.backup)
  • New workflows: Generated from workflows/*.ts to .github/workflows/*.yml

Troubleshooting ​

Types Not Generated ​

Make sure you've added all actions:

bash
gaji add actions/checkout@v5
gaji dev

Build Fails ​

Check for TypeScript errors:

bash
npx tsc --noEmit

YAML Differs from Original ​

Minor formatting differences are normal. Verify functionality, not formatting.

Next Steps ​

Released under the MIT License.