@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 and npm scripts 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.json scripts you select        (namespaced, locked)
                    checked

One Source, No Takeover

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

Surface What sync writes What it never touches
GitHub Actions One 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.
npm scripts Only the namespaced pipeline:* scripts you select, recorded in an ownership lock. Your existing scripts. A name collision fails with ASYNC_PIPELINE_SYNC_CONFLICT instead of overwriting.
Your machine Run 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. npm scripts 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 npm 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 into package.json
  },
  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 test` }),
    build: task({ dependsOn: ["test"], inputs: ["source"], outputs: ["dist/**"], cache: true, run: sh`pnpm 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-framework/async-pipeline with zero runtime dependencies.