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 entirely
  • scopes: [:api_code, :web_code] — treats those scopes as fired without checking files
  • scopes: 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 step

When 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:

  1. Gets the list of changed files from git diff --name-only <base>
  2. Checks if all changed files match the pipeline's ignore patterns — if so, returns :noop (no pipeline)
  3. Tests each scope's files patterns against the changed files (respecting exclude patterns)
  4. Collects the set of "fired" scopes
  5. If any fired scope has activates: :all, all groups are activated
  6. Otherwise, activates groups whose scope is 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
end

When :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/*"])
  # ...
end

The :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 run

Scope 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"
  )
end

The 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.