Skip to main content

Why use the Aspect CLI?

bazel is a build system, not a developer-workflow tool. Every Bazel monorepo eventually grows the same pile of bespoke shell scripts and CI YAML for the things teams actually do every day — format pre-submits, lint enforcement, BUILD-file generation, release-tag delivery, CI-platform reporting. Each is written in a different language, breaks differently, and behaves differently in CI versus on a laptop. There is no shared abstraction, no caching across teams, and no single place to look when something breaks. The Aspect CLI (aspect) replaces that pile with a single, free, open-source task runner you program in Starlark. Built-in tasks like aspect build, aspect test, aspect format, aspect lint, aspect gazelle, and aspect delivery ship with the patterns every Bazel monorepo eventually re-invents — hold-the-line lint that only fails on violations you introduced, change-detection-driven delivery, structured artifact upload, native integration with GitHub Status Checks, Buildkite Annotations, and the equivalent on GitLab and CircleCI. When the built-ins aren’t enough you write custom tasks in Starlark — the same language you already use for .bzl files — and they run identically on every laptop and every CI provider.

Why it’s worth a 10-minute try

  • Zero adoption cost. Apache-licensed, open source, falls through to raw bazel for any subcommand the CLI doesn’t wrap (bazel query, bazel info, etc.). You’re not switching build systems — you’re extending the build system you already have with a programmable task-runner layer.
  • One language for everything. .aspect/config.axl configures the CLI in the same Starlark dialect you write .bzl files in. No new YAML schema, no new template language.
  • Same command in every environment. aspect lint does the same thing on a laptop as in a CI pipeline. Eliminates an entire class of “works on my machine but not in CI” bugs.
  • No vendor lock-in. Stop using it tomorrow and your Bazel build still works. Aspect Workflows is a separate, optional product.
curl -fsSL https://install.aspect.build | bash
In a hurry? The Quickstart walks you from install to a custom task in 10 minutes. See How to install the Aspect CLI for other installation methods. Source code, issues, releases: aspect-build/aspect-cli on GitHub.

Built-in tasks

aspect ships with a set of built-in tasks that work out of the box on any CI provider and on any developer machine:
TaskWhat it does
aspect buildBuild Bazel targets with retry on transient errors and BES streaming
aspect testRun Bazel tests with coverage and test-log upload
aspect runBuild and run a binary target
aspect formatFormat only the files changed in the PR
aspect buildifierFormat Starlark files (opt-in via format.alias())
aspect lintRun linters with hold-the-line strategy
aspect gazelleGenerate and sync BUILD files
aspect deliveryDeliver only targets whose outputs actually changed
Run aspect help to list the tasks available in your repo (built-ins plus any custom ones you’ve added).

What you’ll see

bazel’s output is a wall of INFO: lines. aspect wraps each task in a phased UI, names what each phase is doing, and prints a friendly summary at the end with a per-phase timing breakdown. The simplest example — aspect test on a cached run:
$ aspect test //...
→ 🎬 Running `test` task

→ 🔧 Setup · Running setup

→ 🧪 Test · Spawning bazel test
INFO: Analyzed 147 targets (0 packages loaded, 0 targets configured).
INFO: Found 122 targets and 25 test targets...
INFO: Elapsed time: 0.866s, Critical Path: 0.07s
INFO: 1 process: 31 action cache hit, 1 internal.
INFO: Build completed successfully, 1 total action

Executed 0 out of 25 tests: 25 tests pass.
INFO: Build Event Protocol files produced successfully.

→ ✅ Passed `test` task in 1.2s · Tests passed (cached)
    🔧 Setup   2ms  Prepare the task environment
    🧪 Test   1.2s  Run bazel tests
aspect format shows more phases — it builds the formatter, detects which files changed in the diff against origin/main, formats just those, and reports whether anything was modified:
$ aspect format
→ 🎬 Running `format` task

→ 🔧 Setup · Running setup

→ 🔨 Build · Building formatter (//tools/format)
INFO: Analyzed target //tools/format:format (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target //tools/format:format up-to-date:
  bazel-bin/tools/format/format.bash
INFO: Elapsed time: 0.321s, Critical Path: 0.04s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Build Event Protocol files produced successfully.

→ 🔍 Detect · Detecting changed files
Detecting changed files via local git diff 94f7d5a..663cf4f (git merge-base HEAD origin/main)
Changed files:
  README.md

→ ✨ Format · Formatting 1 file in scope

→ 📋 Diff · Computing format diff
No files in scope were modified.

→ ✅ Passed `format` task in 1.0s · All files are properly formatted
    🔧 Setup     2ms  Prepare the task environment
    🔨 Build   598ms  Build formatter
    🔍 Detect  147ms  Detect changed files
    ✨ Format  260ms  Format files
    📋 Diff     15ms  Detect formatter-induced changes
When aspect buildifier (or aspect format) actually reformats files, it tells you the exact command to re-run locally to apply the same fix — and the raw bazel run invocation it shells out to under the hood, so you’re never locked out of reproducing what just happened:
$ aspect buildifier
→ 🎬 Running `buildifier` task

→ 🔧 Setup · Running setup

→ 🔨 Build · Building formatter (@buildifier_prebuilt//buildifier)
INFO: Analyzed target @@buildifier_prebuilt+//buildifier:buildifier (0 packages loaded, 0 targets configured).
INFO: Found 1 target...
Target @@buildifier_prebuilt+//buildifier:buildifier up-to-date:
  bazel-bin/external/buildifier_prebuilt+/buildifier/buildifier
INFO: Elapsed time: 0.340s, Critical Path: 0.04s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Build Event Protocol files produced successfully.

→ 🔍 Detect · Detecting changed files
Detecting changed files via local git diff 94f7d5a..45815fe (git merge-base HEAD origin/main)
Changed files:
  BUILD.bazel
  README.md
INFO: Skipped 1 file(s) not matching --include-pattern.

→ ✨ Format · Formatting 1 file in scope

→ 📋 Diff · Computing format diff
1 file in scope was modified:
  - BUILD.bazel
Formatted 1 file(s). Review and commit the changes.

🛠️ Fix:
```
aspect buildifier --severity=info -- BUILD.bazel
```

```
# without Aspect CLI
bazel run --run_in_cwd @buildifier_prebuilt//buildifier -- BUILD.bazel
```


→ ✅ Passed `buildifier` task in 786ms · Reformatted 1 file
    🔧 Setup     2ms  Prepare the task environment
    🔨 Build   597ms  Build formatter
    🔍 Detect  150ms  Detect changed files
    ✨ Format    3ms  Format files
    📋 Diff     29ms  Detect formatter-induced changes
aspect lint is the most interesting one because of hold-the-line: the linter can surface findings in files the PR didn’t touch, but the task still passes — only new violations introduced by the PR’s diff fail the build. In the run below ShellCheck found three issues in examples/lint/hello.sh, but since that file isn’t in the changed-file set (only README.md is), the task ends with No findings:
$ aspect lint --aspect=//tools/lint:linters.bzl%shellcheck -- //...
→ 🎬 Running `lint` task

→ 🔧 Setup · Running setup

→ 🔍 Detect · Detecting changed files
Detecting changed files via local git diff 94f7d5a..663cf4f (git merge-base HEAD origin/main)
Changed files:
  README.md [+104 / -30] (changed)

→ 🔬 Lint · Running lint aspects
INFO: Analyzed 147 targets (0 packages loaded, 0 targets configured).
INFO: Found 147 targets...
INFO: Elapsed time: 0.377s, Critical Path: 0.07s
INFO: 1 process: 1 internal.
INFO: Build completed successfully, 1 total action
INFO: Build Event Protocol files produced successfully.

In examples/lint/hello.sh line 10:
if [ $name = "world" ]; then
     ^---^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
if [ "$name" = "world" ]; then


In examples/lint/hello.sh line 17:
cd /tmp
^-----^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.

Did you mean:
cd /tmp || exit


In examples/lint/hello.sh line 19:
echo $name
     ^---^ SC2086 (info): Double quote to prevent globbing and word splitting.

Did you mean:
echo "$name"

For more information:
  https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...
  https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...

→ 🔎 Filter · Filtering diagnostics

🧹 Linters (1): ShellCheck

→ ✅ Passed `lint` task in 756ms · No findings
    🔧 Setup     2ms  Prepare the task environment
    🔍 Detect   93ms  Detect changed files
    🔬 Lint    657ms  Run lint aspects
    🔎 Filter    0ms  Filter diagnostics
The per-phase timing breakdown is the most useful piece on a slow CI step — it tells you whether the time is going into the actual build, the lint discovery, the diff computation, or the artifact upload. On a PR, the same content lands in Buildkite annotations, GitHub Status Checks, and a PR task summary comment.

How tasks are configured

Each task is implemented in AXL, a Starlark dialect. Built-in tasks are zero-config — aspect build, aspect test, and aspect run work the moment you install the CLI. When you need to tune behaviour or register a new task, all knobs live in .aspect/config.axl at your repo root. A minimal example — set --config=ci on every Bazel invocation when running in CI, and upload failing test logs:
.aspect/config.axl
load("@aspect//traits.axl", "BazelTrait")
load("@aspect//feature/artifacts.axl", "ArtifactUpload")

def config(ctx: ConfigContext):
    if bool(ctx.std.env.var("CI")):
        ctx.traits[BazelTrait].extra_flags.extend(["--config=ci"])

    ctx.features[ArtifactUpload].args.upload_test_logs = "failed"
The same config evaluates locally and in CI, so developers and pipelines stay in sync. A more realistic example that exercises several built-ins — for a full live config running in CI, see aspect-build/bazel-examples. The example below sets --config=ci on CI, registers aspect buildifier via format.alias(), declares the lint aspects so CI invocations don’t need to repeat --aspect, points aspect delivery at OCI-push rules, and enables artifact uploads for failed tests, the Bazel profile, and the BEP:
.aspect/config.axl
"""Aspect CLI configuration."""

load("@aspect//feature/artifacts.axl", "ArtifactUpload")
load("@aspect//format.axl", "format")
load("@aspect//traits.axl", "BazelTrait")

buildifier = format.alias(
    defaults = {
        "formatter_target": "@buildifier_prebuilt//buildifier",
        "run_in_cwd": True,
        "include_patterns": [
            "**/BUILD",
            "**/BUILD.bazel",
            "**/MODULE.bazel",
            "**/*.MODULE.bazel",
            "**/WORKSPACE",
            "**/WORKSPACE.bazel",
            "**/*.axl",
            "**/*.bzl",
            "**/*.star",
        ],
    },
    summary = "Format Starlark files using buildifier.",
)

def config(ctx: ConfigContext):
    # Set --config=ci on all bazel commands on all CI environments.
    if bool(ctx.std.env.var("CI")):
        ctx.traits[BazelTrait].extra_flags.extend([
            "--config=ci",
        ])

    # Register the buildifier alias as a CLI command.
    ctx.tasks.add(buildifier)

    # Lint aspects — required by the built-in `aspect lint` task. Same set
    # locally as on CI; CI invocations don't need to repeat the --aspect flags.
    ctx.tasks["lint"].args.aspects = [
        "//tools/lint:linters.bzl%buf",
        "//tools/lint:linters.bzl%checkstyle",
        "//tools/lint:linters.bzl%clippy",
        "//tools/lint:linters.bzl%eslint",
        "//tools/lint:linters.bzl%keep_sorted",
        "//tools/lint:linters.bzl%pmd",
        "//tools/lint:linters.bzl%ruff",
        "//tools/lint:linters.bzl%shellcheck",
    ]

    # Delivery.
    ctx.tasks["delivery"].args.query = "kind(\"oci_push rule\", //...)"
    ctx.tasks["delivery"].args.bazel_flags = [
        "--config=release",
    ]

    # Enable artifact uploads for testlogs, profile, and BEP.
    # upload_test_logs="failed" — the logs from passing tests are noise;
    # failing/flaky tests' logs are the ones anyone would actually open.
    ctx.features[ArtifactUpload].args.upload_test_logs = "failed"
    ctx.features[ArtifactUpload].args.upload_profile = True
    ctx.features[ArtifactUpload].args.upload_bep = True

Custom tasks

When the built-ins don’t cover what you need, write your own. Drop a .axl file into .aspect/, define a task, and it’s automatically discoverable via aspect <name>:
.aspect/codegen.axl
def _impl(ctx: TaskContext) -> int:
    return ctx.bazel.build(*ctx.args.targets).wait().code

codegen = task(
    summary = "Run the code generator.",
    implementation = _impl,
    args = {
        "targets": args.positional(default = ["//gen/..."]),
    },
)
aspect codegen //gen/services/...
See How to run and define tasks for a full walkthrough including arguments, processes, filesystem access, and cleanup.

Aspect Extension Language

AXL — the Aspect Extension Language — is a Starlark dialect for configuring the CLI, writing custom tasks, and extending Bazel with new BUILD-file generators (see Gazelle extensions below) and Build Event Protocol subscribers. Starlark was chosen because Bazel users already know it from .bzl files — the mental model (functions, load statements, deterministic evaluation) transfers directly. AXL draws on Buck’s BXL and Tilt, which use Starlark for the same purpose. The AXL reference documents every type and built-in. For day-to-day work, the Guides section covers what most extensions need. Watch Alex’s BazelCon 2025 talk for an overview of the extension language and the design decisions behind it:

Gazelle extensions

The same Starlark engine powers a separate extension surface: BUILD-file generation. You can teach Gazelle about a new language or project layout in Starlark instead of Go — see How to extend Gazelle BUILD file generation and the Aspect 150 training course.

Running in CI

The same aspect <task> command you run locally works identically in CI. Running tasks in CI has ready-to-paste YAML for GitHub Actions, Buildkite, GitLab CI, and CircleCI. Aspect Workflows self-hosted runners take this further: tasks detect the runner environment automatically and pick up remote cache, remote execution, and pre-warmed NVMe output bases. No extra configuration — the same commands that run locally just go faster.

Live examples

The aspect-build/bazel-examples repo runs aspect <task> pipelines on every commit across all four supported CI providers. Click through to inspect a current build, plus per-task Buildkite annotations, GitHub Status Checks, and a sample PR task summary comment: Live examples.