Recipe: generate your CI
For a small project the CI YAML hurts more than the build: it is the one file nobody can run locally, it drifts from the scripts it calls, and every provider spells it differently. This recipe declares the pipeline in the build, so Zuke writes the workflow file, keeps it in sync on every run, and fails CI the moment the committed copy drifts.
One line in the build
import { Build, cicd, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";
class MyBuild extends Build {
// one line → .github/workflows/ci.yml, one job per target
ci = cicd({ provider: "github", fanOut: true });
lint = target().executes(() => DenoTasks.lint());
test = target()
.dependsOn(this.lint)
.executes(() => DenoTasks.test((s) => s.allowAll()));
}
await run(MyBuild); # write every declared pipeline file
./zuke generate-ci
# the CI gate: fail if a committed file drifted
./zuke generate-ci --check
# the same targets, locally, before you push
./zuke test
The provider is the only required field. The default is a workflow named
CI, triggered on push and pull request to main,
with one job that runs ./zuke; override only what you need.
What generate-ci writes
For the build above, trimmed to its two jobs:
name: CI
"on":
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: zuke-build/zuke@7a62523b… # v1.0.1
- run: ./zuke lint
test:
runs-on: ubuntu-latest
needs: [lint] # ← from .dependsOn(this.lint)
steps:
- uses: zuke-build/zuke@7a62523b… # v1.0.1
- run: ./zuke test -
fanOut: trueturns every target into its own job, wired withneeds:edges that mirrordependsOn. Independent targets run in parallel on the provider, and the workflow's shape is the build's graph — a hand-written YAML could never stay in sync with it. LeavefanOutoff for a single job that runs the whole build. - Every job opens with the SHA-pinned
zuke-build/zukeaction, which hardens the runner and checks the repository out before the target runs. You never write that step, and it is never left unpinned. - The targets are the same ones you run locally. There is no second definition of "what CI does" to keep aligned by hand.
Verified, not just generated
Running any target regenerates the declared files, so you cannot forget
to. On CI, a run verifies the committed files instead and fails
when they have drifted. ./zuke generate-ci --check is the same
verification as a dedicated gate, and --dry-run skips
regeneration altogether.
One declaration, four providers
The same cicd() emits GitHub Actions, GitLab CI, Azure
Pipelines, or Bitbucket Pipelines. Declare one field per provider and every
file follows the same targets; the default path follows the provider:
import { Build, cicd, run, target } from "jsr:@zuke/core";
import { DenoTasks } from "jsr:@zuke/deno";
class Pipeline extends Build {
ci = cicd({ provider: "github", fanOut: true }); // .github/workflows/ci.yml
gitlab = cicd({ provider: "gitlab" }); // .gitlab-ci.yml
azure = cicd({ provider: "azure" }); // azure-pipelines.yml
bitbucket = cicd({ provider: "bitbucket" }); // bitbucket-pipelines.yml
test = target().executes(() => DenoTasks.test((s) => s.allowAll()));
}
await run(Pipeline);
Triggers, a matrix, extra steps, permissions, a timezone-aware schedule —
the pipeline field takes all of it, provider-agnostically.
Code-first CI/CD has the full shape and
Scheduled pipelines covers
{ cron, tz }. A complete project whose only
job is its pipeline, generated files included, is
examples/ci-only.