@async/pipeline

---

title: "@async/pipeline"

description: "One typed pipeline.ts as the single source of truth. GitHub Actions and package scripts or Deno tasks receive only what you choose to sync."

---

@async/pipeline

One typed pipeline.ts is the single source of truth for your verification workflow. It runs on your laptop first — and GitHub Actions, package scripts, or Deno tasks receive only what you explicitly sync to them.

                 ┌─ run ──────▶  your laptop and CI, evidence under .async/
 pipeline.ts ────┤
 (the source)    └─ sync ─────▶  .github/workflows/async-pipeline.yml   (thin bootloader)
                    opt-in,  ─▶  package scripts or Deno tasks          (namespaced, locked)
                    checked

One Source, No Takeover

Most workflow tools want to own your CI or package-manager manifests. @async/pipeline inverts that. Everything is defined once, in pipeline.ts. The surfaces other tools read are generated allowlists you opt into per surface:

SurfaceWhat sync writesWhat it never touches
GitHub ActionsOne pinned, low-permission bootloader workflow plus a lock file. It re-checks itself for drift and delegates job selection back to the CLI.Your other workflows. Task logic never moves into YAML.
Package scripts and Deno tasksOnly the namespaced pipeline:* commands you select, recorded in an ownership lock.Your existing scripts and tasks. A name collision fails with ASYNC_PIPELINE_SYNC_CONFLICT instead of overwriting.
Your machineRun records, logs, and the task cache under .async/ (gitignored).Anything outside .async/.

GitHub Actions stays what it is good at — triggers, runners, permissions, secrets — and stops being where workflow logic lives. Package scripts and Deno tasks stay readable aliases. The graph, caching, retries, and evidence live in one typed file.

Leaving is cheap by design: delete the sync block and the generated files, and your repo still works — the workflow and scripts are plain, readable artifacts, not hooks into a framework.

The full story: Sync: choose what GitHub and package managers see.

Sixty-Second Start

pnpm add -D @async/pipeline
// pipeline.ts
import { definePipeline, job, sh, task, trigger } from "@async/pipeline";

export default definePipeline({
  name: "app",
  cache: "file:local",
  triggers: {
    pr: trigger.github({ events: ["pull_request"] }),
    main: trigger.github({ events: ["push"], branches: ["main"] })
  },
  sync: {
    github: true,   // generate the bootloader workflow
    tasks: true     // sync job scripts or Deno tasks
  },
  namedInputs: {
    source: ["src/**/*.ts", "package.json", "pnpm-lock.yaml", "tsconfig.json"]
  },
  tasks: {
    typecheck: task({ inputs: ["source"], cache: true, run: sh`pnpm typecheck` }),
    test: task({ dependsOn: ["typecheck"], inputs: ["source"], cache: true, run: sh`pnpm run test` }),
    build: task({ dependsOn: ["test"], inputs: ["source"], outputs: ["dist/**"], cache: true, run: sh`pnpm run build` })
  },
  jobs: {
    verify: job({ target: "build", trigger: ["pr", "main"] })
  }
});
async-pipeline run verify        # run the graph locally, cached and parallel
async-pipeline sync generate     # write the workflow + scripts you opted into
async-pipeline sync check        # fail when any synced surface is stale

Inspect what happened:

ls .async/runs
cat .async/runs/<run-id>/summary.md

The Mental Model

tasks     = what can run
jobs      = named entrypoints
triggers  = when jobs should run
sync      = generated files to keep current

Tasks, jobs, and triggers describe your workflow. Sync is the explicit boundary: it lists exactly which generated files exist outside pipeline.ts, and sync check fails when they drift.

Docs

The package is MIT-licensed and ships from github.com/async/pipeline with zero runtime dependencies.