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
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.
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
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.
gc, retries.The package is MIT-licensed and ships from github.com/async-framework/async-pipeline with zero runtime dependencies.