The activation engine determines which groups and steps run for a given build. It combines branch policies, scope-based change detection, targeting, dependency propagation, and branch filtering into a single resolution pipeline.
The Algorithm
Activation runs through these phases in order:
1. Branch Policy
The engine matches the current branch against defined branch policies:
branch("main", scopes: :all, disable: [:targeting])
branch("release/*", scopes: [:api_code, :web_code])scopes: :all— activates every group, skips file-based detection entirelyscopes: [:api_code, :web_code]— treats those scopes as fired without checking filesscopes: nil(or no matching policy) — proceeds to file-based detection
2. Targeting
If targeting is not disabled for the current branch, the engine checks for explicit targets in the commit message or CI_TARGET environment variable:
[ci:api] Fix login bug -> activates :api group
[ci:api/test] Fix flaky test -> activates :api group, filters to :test stepWhen targets are found, the engine activates only the targeted groups (plus their transitive dependencies). File-based scope detection is skipped entirely.
See the Targeting guide for full syntax.
3. Scope Matching
If no branch policy override and no targeting, the engine:
- Gets the list of changed files from
git diff --name-only <base> - Checks if all changed files match the pipeline's
ignorepatterns — if so, returns:noop(no pipeline) - Tests each scope's
filespatterns against the changed files (respectingexcludepatterns) - Collects the set of "fired" scopes
- If any fired scope has
activates: :all, all groups are activated - Otherwise, activates groups whose
scopeis in the fired set
scope(:api_code, files: ["apps/api/**"])
scope(:infra, files: ["infra/**"], activates: :all)Changing infra/main.tf fires the :infra scope, which has activates: :all, so every group runs.
4. Force Activation
Groups can be force-activated via environment variables:
force_activate(%{"FORCE_DEPLOY" => [:web, :deploy], "FORCE_ALL" => :all})When FORCE_DEPLOY=true is set, :web and :deploy are added to the active set. Force-activated groups bypass the only branch filter — they run on any branch.
5. Dependency Propagation
Two kinds of dependency propagation occur:
Pull dependencies: If group A is active and depends_on: :B, group B is pulled into the active set (even if B's scope didn't fire).
Scopeless propagation: Groups without a scope are activated when any of their depends_on groups are active. This is useful for deploy/release groups that should run whenever their upstream groups run.
group :api do
scope(:api_code)
# ...steps
end
group :deploy do
depends_on(:api)
only("main")
# ...steps
endWhen :api is activated by scope matching, :deploy is pulled in because it depends_on: :api and has no scope of its own.
6. only Branch Filter
After all activation and propagation, groups are filtered by their only field:
group :deploy do
only("main")
# ...
end
group :release do
only(["main", "release/*"])
# ...
endThe :deploy group is removed if the current branch is not main. Glob patterns are supported.
Force-activated groups bypass this filter.
7. Step Filter
If targeting specified individual steps (e.g. [ci:api/test]), only those steps (plus their intra-group dependencies) are kept:
# Given [ci:api/test]:
# - :api group is activated
# - Only the :test step (and any steps it depends_on) run
# - If :test depends_on :setup, both runScope Patterns
Scopes use glob patterns with ** and *:
**matches any path segment(s), including nested directories*matches anything except/- Patterns without
/also match against the basename
scope(:api_code,
files: ["apps/api/**", "libs/shared/**"],
exclude: ["**/*.md", "apps/api/docs/**"]
)A file must match at least one files pattern and not match any exclude pattern to fire the scope.
Ignore Patterns
Pipeline-level ignore patterns prevent activation when only ignored files changed:
ignore(["docs/**", "*.md", "LICENSE"])If a commit changes only README.md and docs/guide.md, the pipeline returns :noop and no Buildkite steps are generated.
If the commit also changes apps/api/lib/user.ex, the ignore patterns are not applied — normal scope detection runs.
Cross-Group Step Dependencies
Steps can depend on specific steps in other groups using tuple syntax:
group :deploy do
label(":rocket: Deploy")
depends_on([:api, :web])
only("main")
step(:deploy_api,
label: "Deploy API",
depends_on: {:api, :test},
command: "./scripts/deploy-api.sh"
)
step(:deploy_web,
label: "Deploy Web",
depends_on: {:web, :build},
command: "./scripts/deploy-web.sh"
)
endThe tuple {:api, :test} resolves to the Buildkite step key "api-test". This lets you express fine-grained dependencies — the deploy step waits for the specific upstream step, not just the group as a whole.
You can also mix cross-group and intra-group dependencies in a list:
step(:integration,
label: "Integration Tests",
depends_on: [{:api, :build}, {:web, :build}, :setup],
command: "./scripts/integration-test.sh"
)Here :setup refers to another step within the same group, while the tuples reference steps in the :api and :web groups.
activates: :all
A scope with activates: :all causes all groups to activate when that scope fires:
scope(:root_config,
files: [".buildkite/**", "mix.exs", ".tool-versions"],
activates: :all
)This is useful for CI config files, lock files, or shared configuration that could affect any part of the pipeline.