npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

gagen

v0.4.0

Published

Generate complex GitHub Actions YAML files using a declarative API.

Downloads

807

Readme

gagen

JSR npm Version

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

#!/usr/bin/env -S deno run --allow-read=ci.generated.yml --allow-write=ci.generated.yml
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",
});

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.

# 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
      - name: Clippy
        if: github.ref != 'refs/heads/main'
        run: cargo clippy
      - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282
        if: github.ref != 'refs/heads/main'
      - name: Deno Lint
        if: github.ref != 'refs/heads/main'
        run: deno lint
      - name: Test
        run: cargo test

# gagen:pin actions/checkout@v6 = de0fac2e4500dabe0009e67214ff5f5447ce83dd
# gagen:pin denoland/setup-deno@v2 = 667a34cdef165d8d2b2e98dde39547c9daac7282

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:

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:

# generate the output
npx gagen

# lint the output
npx gagen --lint

The requires your scripts to use the writeOrLint function.

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:

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

It will output:

steps:
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# gagen:pin actions/checkout@v6 = 11bd71901bbe5b1630ceea73d27597364c9af683

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:

./ci.ts --update-pins

Pinning can be disabled by setting pinDeps to false:

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

A custom resolver can also be provided:

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

Conditions

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

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:

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:

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:

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:

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.

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:

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:

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:

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:

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:

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