Skip to main content
aspect delivery builds and dispatches Bazel targets (services, binaries, containers) to their destination. It uses content-hash change detection: a target is re-delivered only when its build outputs changed, not because a commit landed on main. If a commit changes the outputs of //backend/api:server but not //frontend/web:app, delivery re-deploys only the API service; the frontend is already delivered for that content.
The default —track-state=true records delivery state in deliveryd, which runs on Aspect Workflows runners. Off a Workflows runner, run with —mode=always —track-state=false, adding —dry-run for a preview. —mode=selective can’t run there.

Selective delivery in depth

A deploy keyed on the commit SHA re-deploys everything on every push to main. aspect delivery keys on content hashes instead, in phases: Phase 1: Hash extraction A custom hashsum Bazel aspect runs alongside the normal build. It produces a per-target action digest by re-running the build with --experimental_remote_require_cached and reading the digests from a gRPC log. The result is a stable hash of each target’s build outputs, not of its source inputs or the commit. Phase 2: State query deliveryd, which runs on Aspect Workflows CI runners, stores delivery history keyed by --task:name (plus --salt), label and digest. Before dispatching, the task asks it whether each (label, digest) pair was already delivered. If it was, the target is skipped; if not, it’s a delivery candidate. Phase 3: Fused download and dispatch The remaining candidates download their runfiles and dispatch as one pipelined stream: each target runs as soon as its runfiles are on disk, so a slow target doesn’t hold up ones that are ready. Up to --max-parallelization deliveries run at once (default 1), and deliveryd records each result.

Content hashes, not commit SHAs

A commit SHA says what was committed, not what changed in the outputs. Two commits that produce identical binaries, such as a README edit, have different SHAs but the same content hash, so identical outputs aren’t re-delivered.

Uncacheable actions fail the task

Change detection needs a cache entry for every action in a deliverable’s dependency graph in order to compute its digest. If any action in that graph is uncacheable, typically a non-hermetic genrule (one that shells out to git or embeds a timestamp) or a stamped action, the digest can’t be computed. By default aspect delivery treats this as a hard failure: the task exits non-zero and prints the offending target(s), the mnemonic(s) of the uncacheable action(s), and how to resolve it:
To fix each culprit:
  • Make the action deterministic and cacheable (remove the git/date call, drop no-cache tags, etc.).
  • Tag it no-remote or local so Bazel runs it locally on every invocation and never blocks the digest lookup on a missing remote cache entry.
If your repo knowingly ships uncacheable actions on delivery targets, downgrade the failure to a warning with --warn-unresolved-digest:
The task marks affected targets WARN, leaves them undelivered, and exits zero. Fixing the uncacheable action is better: with the warning, those targets never deliver.

Configuration

Delivery mode

Use selective for production pipelines. always suits initial setup, debugging and forced rollouts. --mode=selective needs delivery state, so it can’t be combined with --track-state=false.

Resolving delivery targets

--query takes any Bazel query expression, such as kind(...), attr(...), unions and intersections, and takes precedence over positional targets. With neither, nothing is delivered. Targets tagged manual are included when the expression matches them. --limit caps how many resolved targets deliver (default 0, no limit).

Forcing re-delivery

Repeatable. Delivers the listed targets even if their content hash matches a prior delivery, for rollbacks or emergency re-deploys. Each label must already be in the resolved delivery set; an unknown label fails the task at startup. Labels in the ASPECT_WORKFLOWS_DELIVERY_FORCE_TARGETS environment variable, separated by whitespace, are added to the list.

Dry run

Runs phases 1 and 2 (hash extraction, state query) and prints which targets would deliver, without building the release artifacts or dispatching. Use it to check your query and change detection before the first real deploy. --dry-run=build also runs the final build, for the manifest’s on-disk paths and the runnable checks.

Parallelism

Default: 1, one target at a time. Raise it when your deploy targets tolerate concurrency; above 1, each target’s output goes to its own log.

Stamping and Bazel flags

Delivered binaries are stamped by default: --release-bazel-flag defaults to --stamp, so version-control info is embedded with no configuration. Which flag carries a Bazel flag matters for change detection:
Put —stamp, —workspace_status_command, and any —config that enables them in —release-bazel-flag, not —bazel-flag.The change-detection digest (phases 1 and 2) is computed from the flags —bazel-flag feeds. Stamping injects volatile values (commit SHA, build time, workspace status) into those phases, so the digest shifts on every commit, change detection sees every target as new, and everything re-delivers every time. —release-bazel-flag applies only to the final delivery build, after change detection has decided what to deliver, so stamped artifacts stay stamped without polluting the digest.Watch for this via —config: a release config like build:release —stamp —workspace_status_command=… in .bazelrc pulls stamping in the moment it’s activated, so —config=release belongs in —release-bazel-flag too. Only reach for —bazel-flag once you’ve confirmed a config adds no non-determinism.
Set them once in .aspect/config.axl. A release --config typically enables stamping, so it goes in release_bazel_flags:
.aspect/config.axl
Assigning release_bazel_flags replaces the default [“—stamp”], so include —stamp in your list if you still want stamping. To turn stamping off, set release_bazel_flags = [“—nostamp”] or pass —release-bazel-flag=—nostamp.
Or pass either flag per invocation:

Isolate the delivery Bazel server (Aspect Workflows)

On an Aspect Workflows runner, every task shares one warm Bazel server (the runner-derived --output_base), keyed by its analysis cache. The delivery task’s final build stamps and applies --config=release, which changes the build options, so on the shared server it discards the warm analysis cache the next build/test job on that runner would have reused (Build options … have changed, discarding analysis cache). --workflows-runner-delivery-output-base-suffix=<suffix> moves that release build to its own persistent Bazel server, so its analysis stays warm across delivery runs without invalidating the cache the other jobs share. The change-detection build stays on the shared server; the checksum phase always uses its own.
Set it once in .aspect/config.axl:
.aspect/config.axl
It’s a no-op off a Workflows runner. Keep incompatible tasks off each other’s analysis cache covers how output-base suffixes work and the runner-wide --workflows:runner-output-base-suffix for other tasks.

Delivery manifest

Every invocation produces a structured delivery manifest: a JSON record of every target’s outcome (ok / skip / warn / fail / pending), the resolved CI metadata, and any per-target enrichment your config.axl attached. It’s uploaded as a CI artifact (one labeled download link per file on the GitHub Status Check / Buildkite annotation); pass --manifest-file=<path> to also write it to disk. Customize the delivery manifest covers the schema and the DeliveryTrait hooks that enrich, render, upload, and act on it.

Options

It also takes --bazel-startup-flag, --announce-bazel-*, --bes-backend and --bes-header, as described for aspect run.

CI examples