Declarative Buildkite pipeline generation for monorepos, written in Elixir.
Define your CI pipeline using Pipette.DSL — a declarative syntax
built on Spark. Pipette inspects changed files, applies branch
policies and scope rules, then generates a Buildkite YAML pipeline
containing only the groups that need to run.
Quick start
defmodule MyApp.Pipeline do
use Pipette.DSL
branch "main", scopes: :all, disable: [:targeting]
scope :api_code, files: ["apps/api/**"]
scope :web_code, files: ["apps/web/**"]
ignore ["docs/**", "*.md"]
group :api do
label ":elixir: API"
scope :api_code
step :test, label: "Test", command: "mix test"
step :lint, label: "Lint", command: "mix credo"
end
group :web do
label ":globe_with_meridians: Web"
scope :web_code
step :test, label: "Test", command: "pnpm test"
step :build, label: "Build", command: "pnpm build"
end
group :deploy do
label ":rocket: Deploy"
depends_on [:api, :web]
only "main"
step :push, label: "Push", command: "./deploy.sh"
end
trigger :notify do
pipeline "notify-pipeline"
depends_on :deploy
only "main"
end
force_activate %{"FORCE_DEPLOY" => [:deploy]}
endRunning the pipeline
# In your .buildkite/pipeline.exs:
Pipette.run(MyApp.Pipeline)
# Dry run (returns YAML without uploading):
{:ok, yaml} = Pipette.generate(MyApp.Pipeline)How it works
- Reads the compiled
%Pipette.Pipeline{}from the DSL module (validation and key generation happen at compile time via Spark) - Builds a
%Pipette.Context{}from Buildkite environment variables - Determines changed files via
git diff - Resolves force-activated groups from environment variables
- Runs the activation engine to determine which groups to include
- Resolves trigger steps based on active groups and branch filters
- Serializes active groups and triggers to Buildkite YAML
- Uploads the YAML via
buildkite-agent pipeline upload(or returns it in dry-run mode)
Options
Both run/2 and generate/2 accept these options:
:env— environment variable map (defaults toSystem.get_env()):dry_run— whentrue, returns YAML instead of uploading (defaults toDRY_RUN=1):changed_files— explicit list of changed files (skipsgit diff):extra_groups— 2-arity function(ctx, changed_files) -> [Group.t()]for dynamic groups
Testing
Use generate/2 with explicit :env and :changed_files to test activation logic:
{:ok, yaml} = Pipette.generate(MyApp.Pipeline,
env: %{
"BUILDKITE_BRANCH" => "feature/login",
"BUILDKITE_PIPELINE_DEFAULT_BRANCH" => "main",
"BUILDKITE_COMMIT" => "abc123",
"BUILDKITE_MESSAGE" => "Add login"
},
changed_files: ["apps/api/lib/user.ex"]
)
assert yaml =~ "api"
refute yaml =~ "web"
Summary
Functions
Generate pipeline YAML without uploading. Convenience wrapper around run/2
with dry_run: true.
Returns {:ok, yaml} when groups are activated, or :noop when no groups match.
Examples
{:ok, yaml} = Pipette.generate(MyApp.Pipeline,
env: %{"BUILDKITE_BRANCH" => "main", ...},
changed_files: ["apps/api/lib/user.ex"]
)
:noop = Pipette.generate(MyApp.Pipeline,
env: %{"BUILDKITE_BRANCH" => "feature/docs", ...},
changed_files: ["README.md"]
)
Run the pipeline: validate, resolve activation, and upload to Buildkite.
Returns:
:ok— pipeline uploaded successfully{:ok, yaml}— dry run mode, returns the YAML string:noop— no groups activated (e.g. docs-only changes){:error, message}— upload failed
Options
:env— environment variable map (defaults toSystem.get_env()):dry_run— return YAML instead of uploading (defaults toDRY_RUN=1env var):changed_files— explicit list of changed files (skipsgit diff):extra_groups—fn ctx, changed_files -> [Group.t()]for dynamic groups
Examples
# Normal CI usage (reads env, runs git diff, uploads):
Pipette.run(MyApp.Pipeline)
# Dry run from command line:
# DRY_RUN=1 elixir .buildkite/pipeline.exs
# Programmatic dry run with explicit inputs:
{:ok, yaml} = Pipette.run(MyApp.Pipeline,
dry_run: true,
env: %{"BUILDKITE_BRANCH" => "main", ...},
changed_files: ["apps/api/lib/user.ex"]
)