For monorepos where the set of packages or components isn't known at compile time, Pipette supports generating groups dynamically at runtime via the extra_groups callback.
The extra_groups Option
Pass a 2-arity function to Pipette.run/2 or Pipette.generate/2:
Pipette.run(MyApp.Pipeline,
extra_groups: fn ctx, changed_files ->
# Return a list of %Pipette.Group{} structs
[]
end
)The function receives:
ctx— a%Pipette.Context{}struct with branch, commit, message, etc.changed_files— the list of changed file paths (or:allif change detection failed)
Package Discovery Example
Discover packages in a packages/ directory and generate a group for each with format, test, and build steps:
# .buildkite/pipeline.exs
Mix.install([{:buildkite_pipette, "~> 0.4"}])
defmodule MyApp.Pipeline do
use Pipette.DSL
branch("main", scopes: :all)
scope(:api_code, files: ["apps/api/**"])
group :api do
label(":elixir: API")
scope(:api_code)
step(:test, label: "Test", command: "mix test", timeout_in_minutes: 15)
end
end
Pipette.run(MyApp.Pipeline,
extra_groups: fn _ctx, _changed_files ->
"packages"
|> File.ls!()
|> Enum.filter(&File.dir?(Path.join("packages", &1)))
|> Enum.map(fn pkg ->
%Pipette.Group{
name: String.to_atom(pkg),
label: ":package: #{pkg}",
key: pkg,
steps: [
%Pipette.Step{
name: :format,
label: "Format",
command: "cd packages/#{pkg} && mix format --check-formatted",
key: "#{pkg}-format",
timeout_in_minutes: 5
},
%Pipette.Step{
name: :test,
label: "Test",
command: "cd packages/#{pkg} && mix test",
key: "#{pkg}-test",
timeout_in_minutes: 15,
env: %{"MIX_ENV" => "test"},
retry: %{automatic: [%{exit_status: -1, limit: 2}]}
},
%Pipette.Step{
name: :build,
label: "Build",
command: "cd packages/#{pkg} && mix compile --warnings-as-errors",
key: "#{pkg}-build",
depends_on: "#{pkg}-format",
timeout_in_minutes: 10
}
]
}
end)
end
)Filtering by Changed Files
You can use the changed_files argument to only generate groups for packages that actually changed:
extra_groups: fn _ctx, changed_files ->
packages =
"packages"
|> File.ls!()
|> Enum.filter(&File.dir?(Path.join("packages", &1)))
case changed_files do
:all ->
# Can't determine changes — run everything
build_groups(packages)
files ->
changed_packages =
Enum.filter(packages, fn pkg ->
Enum.any?(files, &String.starts_with?(&1, "packages/#{pkg}/"))
end)
build_groups(changed_packages)
end
endUsing Pipette.Constructors
If your dynamic group logic lives in a separate module (not the DSL module), you can
import Pipette.Constructors for cleaner syntax instead of raw structs:
defmodule MyApp.PackageDiscovery do
import Pipette.Constructors, only: [step: 2, group: 2]
def discover(_ctx, _changed_files) do
[group(:extra, label: "Extra", key: "extra", steps: [
step(:check, label: "Check", command: "echo ok", key: "extra-check")
])]
end
endNote: You cannot import
Pipette.Constructorsin the same module thatusesPipette.DSL— thestep/2andgroup/2names conflict with Spark's DSL macros. Use raw structs or a separate module.
Important Notes
- Keys must be unique: Dynamic group and step keys must not collide with static pipeline groups. Use the package name as a prefix.
- No validation: Extra groups bypass compile-time Spark verifiers — scope references, dependency references, and cycle detection are not checked for dynamically added groups.
- Appended after activation: Extra groups are added after the activation engine runs. They always appear in the pipeline regardless of scope matching.
- Branch filtering: Extra groups do not go through
onlybranch filtering. If you need branch-specific behavior, filter in your callback usingctx.branch.
Branch-Aware Dynamic Groups
extra_groups: fn ctx, _changed_files ->
if ctx.is_default_branch do
[
%Pipette.Group{
name: :publish,
label: ":rocket: Publish Packages",
key: "publish",
steps: [
%Pipette.Step{
name: :publish,
label: "Publish",
command: "./scripts/publish-all.sh",
key: "publish-all",
timeout_in_minutes: 15,
concurrency: 1,
concurrency_group: "publish-packages"
}
]
}
]
else
[]
end
end