# gagen

[![JSR](https://jsr.io/badges/@david/gagen)](https://jsr.io/@david/gagen)
[![npm Version](https://img.shields.io/npm/v/gagen.svg?style=flat)](http://www.npmjs.com/package/gagen)

Generate complex GitHub Actions YAML files using a declarative API.

Gagen lets you define workflows in TypeScript with a fluent, declarative API
that automatically resolves step ordering and propagates conditions. The
condition propagation helps skip unnecessary setup steps and eliminates needing
to repeat condition text over and over again.

Additionally, gagen automatically pins dependencies in the output so your
initial code is more easily maintainable.

## Basic usage

```ts
// .github/workflows/ci.ts
import { conditions, step, workflow } from "gagen";

const checkout = step({
  uses: "actions/checkout@v6",
});

const test = step.dependsOn(checkout)({
  name: "Test",
  run: "cargo test",
});

const installDeno = step({
  uses: "denoland/setup-deno@v2",
});

const lint = step
  .dependsOn(checkout)
  // this condition gets propagated to installDeno, but not checkout
  .if(conditions.isBranch("main").not())(
    {
      name: "Clippy",
      run: "cargo clippy",
    },
    step.dependsOn(installDeno)({
      name: "Deno Lint",
      run: "deno lint",
    }),
  );

// only specify the leaf steps — the other steps are pulled in automatically
workflow({
  name: "ci",
  on: ["push", "pull_request"],
  jobs: [{
    id: "build",
    runsOn: "ubuntu-latest",
    steps: [lint, test],
  }],
}).writeOrLint({
  filePath: new URL("./ci.generated.yml", import.meta.url),
  header: "# GENERATED BY ./ci.ts -- DO NOT DIRECTLY EDIT",
});
```

Generate via the `gagen` cli:

```sh
# or alternatively run the script directly
npx gagen
```

This generates a `ci.generated.yml` with steps in the correct order and figures
out that it should only install deno when the lint step should be run and it
defers that step only until it's necessary.

```yaml
# GENERATED BY ./ci.ts -- DO NOT DIRECTLY EDIT

name: ci
on:
  - push
  - pull_request
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
      - name: Clippy
        if: github.ref != 'refs/heads/main'
        run: cargo clippy
      - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2
        if: github.ref != 'refs/heads/main'
      - name: Deno Lint
        if: github.ref != 'refs/heads/main'
        run: deno lint
      - name: Test
        run: cargo test
```

When run normally, this writes `ci.generated.yml`. When run with `--lint`, it
reads the existing file and compares the parsed YAML — exiting with a non-zero
code if they differ. This lets you add a CI step to verify the generated file is
up to date:

```ts
const lintStep = step({
  name: "Lint CI generation",
  // alternatively, use `npx gagen --lint` to lint all the files
  // in the `.github/workflows` folder
  run: "./.github/workflows/ci.ts --lint",
});
```

## CLI

If you store your generations scripts beside your `.yml` files in the
`.github/workflows` folder, then you can automatically run all these scripts by
using the `gagen` binary:

```sh
# generate the output
npx gagen

# lint the output
npx gagen --lint

# pull version bumps from generated yaml back into the source scripts
# (useful after dependabot updates a .generated.yml)
npx gagen --pull-versions
```

The requires your scripts to use the `writeOrLint` function.

### `--pull-versions`

Dependabot updates the inline version comment on each `uses:` line of the
generated YAML (e.g. `actions/checkout@<new-hash> # v7`). The source script
still reads `v6`, so the next regeneration would revert the bump. Running
`npx gagen --pull-versions` scans every YAML in `.github/workflows`, collects
the current version for each action, then rewrites `"owner/repo@<old>"` literals
in the script files to match. The YAML is already up to date, so no regeneration
is needed.

Limitations:

- Only literal `"owner/repo@ref"` strings (double or single quoted) are
  rewritten. Template literals with substitutions and computed uses values are
  left alone.
- If the same action appears in multiple YAML files with different versions, it
  is reported as a conflict and skipped — resolve it manually.

## Dependency pinning—the output is a lockfile

By default, `writeOrLint` pins action references to their resolved commit hashes
then stores that hash value locked in the output.

For example, if you write the following step in a workflow:

<!-- deno-fmt-ignore -->

```ts
step({
  uses: "actions/checkout@v6",
})
```

It will output:

```yaml
steps:
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v6
```

The inline comment records the original ref so gagen can re-resolve it when you
run with `--update-pins`.

Then the next time it runs, it will read the output to get a locked set of
dependencies.

To force re-resolving all pins, run with the `--update-pins` flag:

```sh
./ci.ts --update-pins
```

Pinning can be disabled by setting `pinDeps` to `false`:

```ts
wf.writeOrLint({ filePath, pinDeps: false });
```

A custom resolver can also be provided:

```ts
wf.writeOrLint({
  filePath,
  pinDeps: { resolve: (owner, repo, ref) => lookupHash(owner, repo, ref) },
});
```

## Conditions

Build type-safe GitHub Actions expressions with a fluent API:

```ts
import { expr } from "gagen";

const ref = expr("github.ref");
const os = expr("matrix.os");

// simple comparisons
ref.equals("refs/heads/main");
// => github.ref == 'refs/heads/main'

ref.startsWith("refs/tags/").not();
// => !startsWith(github.ref, 'refs/tags/')

// compose with .and() / .or()
os.equals("linux").and(ref.startsWith("refs/tags/"));
// => matrix.os == 'linux' && startsWith(github.ref, 'refs/tags/')

// use on steps
const deploy = step.dependsOn(build).if(
  ref.equals("refs/heads/main").and(os.equals("linux")),
)({
  name: "Deploy",
  run: "deploy.sh",
});
```

## Common conditions

The `conditions` object provides composable helpers for common GitHub Actions
patterns:

```ts
import { conditions } from "gagen";

const { status, isTag, isBranch, isEvent } = conditions;

// status check functions
status.always(); // always()
status.failure(); // failure()
status.success(); // success()
status.cancelled(); // cancelled()

// ref checks
isTag(); // startsWith(github.ref, 'refs/tags/')
isTag("v1.0.0"); // github.ref == 'refs/tags/v1.0.0'
isBranch("main"); // github.ref == 'refs/heads/main'

// event checks
isEvent("push"); // github.event_name == 'push'
isEvent("pull_request"); // github.event_name == 'pull_request'

// compose freely with .and() / .or() / .not()
const deploy = step.dependsOn(build).if(isBranch("main").and(isEvent("push")))({
  name: "Deploy",
  run: "deploy.sh",
});

const cleanup = step.dependsOn(build).if(status.always())({
  name: "Cleanup",
  run: "rm -rf dist",
});
```

## Ternary expressions

Build GitHub Actions ternary expressions (`condition && trueVal || falseVal`)
with a fluent `.then().else()` chain:

```ts
const os = expr("matrix.os");

// simple ternary
const runner = os.equals("linux").then("ubuntu-latest").else("macos-latest");
// => matrix.os == 'linux' && 'ubuntu-latest' || 'macos-latest'

// multi-branch with elseIf
const runner = os.equals("linux").then("ubuntu-latest")
  .elseIf(os.equals("macos")).then("macos-latest")
  .else("windows-latest");
// => matrix.os == 'linux' && 'ubuntu-latest' || matrix.os == 'macos' && 'macos-latest' || 'windows-latest'

// use in job config
workflow({
  // ...,
  jobs: [
    { id: "build", runsOn: runner, steps: [test] },
  ],
});
```

Values can be strings, numbers, booleans, or `ExpressionValue` references.
Conditions with `||` are automatically parenthesized to preserve correct
evaluation order.

## Condition propagation

Conditions on leaf steps automatically propagate backward to their dependencies.
This avoids running expensive setup steps when they aren't needed:

```ts
const checkout = step({ uses: "actions/checkout@v6" });
const build = step.dependsOn(checkout)({ run: "cargo build" });
const test = step.dependsOn(build).if(expr("matrix.job").equals("test"))({
  run: "cargo test",
});

// only test is passed — checkout and build inherit its condition
workflow({
  ...,
  jobs: [
    { id: "test", runsOn: "ubuntu-latest", steps: [test] },
  ],
});
// all three steps get: if: matrix.job == 'test'
```

When multiple leaf steps have different conditions, dependencies get the OR of
those conditions:

```ts
const test = step.dependsOn(checkout).if(jobExpr.equals("test"))({
  run: "cargo test",
});
const bench = step.dependsOn(checkout).if(jobExpr.equals("bench"))({
  run: "cargo bench",
});

workflow({
  ...,
  jobs: [
    { id: "test", runsOn: "ubuntu-latest", steps: [test, bench] },
  ],
});
// checkout gets: if: matrix.job == 'test' || matrix.job == 'bench'
```

## Step outputs and job dependencies

Steps can declare outputs. When a job references another job's outputs, the
`needs` dependency is inferred automatically.

```ts
import { job, step, workflow } from "gagen";

const checkStep = step({
  id: "check",
  name: "Check if draft",
  run: `echo 'skip=true' >> $GITHUB_OUTPUT`,
  outputs: ["skip"],
});

// use job() when you need a handle for cross-job references
const preBuild = job("pre_build", {
  runsOn: "ubuntu-latest",
  steps: [checkStep],
  outputs: { skip: checkStep.outputs.skip },
});

// preBuild.outputs.skip is an ExpressionValue — using it in the `if`
// automatically adds needs: [pre_build] to this job
const wf = workflow({
  name: "ci",
  on: ["push", "pull_request"],
  jobs: [
    preBuild,
    {
      id: "build",
      runsOn: "ubuntu-latest",
      if: preBuild.outputs.skip.notEquals("true"),
      steps: [buildStep],
    },
  ],
});
```

## Diamond dependencies

Steps shared across multiple dependency chains are deduplicated and
topologically sorted:

```ts
const checkout = step({ name: "Checkout", uses: "actions/checkout@v6" });
const buildA = step.dependsOn(checkout)({ name: "Build A", run: "make a" });
const buildB = step.dependsOn(checkout)({ name: "Build B", run: "make b" });
const integrate = step.dependsOn(buildA, buildB)({
  name: "Integrate",
  run: "make all",
});

workflow({
  ...,
  jobs: [
    { id: "ci", runsOn: "ubuntu-latest", steps: [integrate] },
  ],
});
// resolves to: checkout → buildA → buildB → integrate
// checkout appears only once
```

## Ordering constraints

Use `comesAfter()` to control step ordering without creating a dependency.
Unlike `dependsOn()`, this does not pull in the step — it only ensures ordering
when both steps are present in the same job:

```ts
const setupDeno = step({
  uses: "denoland/setup-deno@v2",
  with: { "deno-version": "canary" },
});

// ensure checkout runs after setupDeno, without making checkout depend on it
const checkout = step.comesAfter(setupDeno)({ uses: "actions/checkout@v6" });
const build = step.dependsOn(checkout)({ run: "cargo build" });
const lint = step.dependsOn(setupDeno, checkout)({ run: "deno lint" });

workflow({
  ...,
  jobs: [
    { id: "ci", runsOn: "ubuntu-latest", steps: [build, lint] },
  ],
});
// resolves to: setupDeno → checkout → build → lint
```

If the constraint conflicts with existing dependencies (creating a cycle), the
library throws with the cycle path:

```
Error: Cycle detected in step ordering: A → B → A
```

## Typed matrix

`defineMatrix()` gives you typed access to matrix values:

```ts
import { workflow, defineMatrix } from "gagen";

const matrix = defineMatrix({
  include: [
    { runner: "ubuntu-latest" },
    { runner: "macos-latest" },
  ],
});

matrix.runner; // ExpressionValue("matrix.runner") — autocompletes
matrix.foo; // TypeScript error — not a matrix key

workflow({
  ...,
  jobs: [
    {
      id: "build",
      runsOn: matrix.runner,
      strategy: { matrix },
      steps: [test],
    },
  ],
});
```

## Artifacts

Link upload and download artifact steps across jobs with automatic `needs`
inference:

```ts
import { artifact, step, workflow } from "gagen";

const buildOutput = artifact("build-output");

const wf = workflow({
  name: "CI",
  on: ["push", "pull_request"],
  jobs: [
    {
      id: "build",
      runsOn: "ubuntu-latest",
      steps: [
        step({ name: "Build", run: "make build" }),
        buildOutput.upload({ path: "dist/" }),
      ],
    },
    // `needs: [build]` is inferred automatically from the artifact link
    {
      id: "deploy",
      runsOn: "ubuntu-latest",
      steps: [
        buildOutput.download({ dirPath: "output/" }),
        {
          name: "Deploy",
          run: "make deploy",
        },
      ],
    },
  ],
});
```

Upload requires `path` (a glob pattern for files to upload). Download accepts an
optional `dirPath` (the directory to download into).

The artifact version and default retention days can be configured:

```ts
const buildOutput = artifact("build-output", {
  version: "v3",
  retentionDays: 5,
});
```
