aspect delivery builds and dispatches Bazel targets (services, binaries, containers) to their destination. Its defining feature is content-hash-based change detection: a target is only re-delivered if its build outputs have actually changed, not just because a commit landed on main.
On a monorepo with hundreds of services, this prevents cascade deploys. If a commit touches //backend/api:server but not //frontend/web:app, delivery re-deploys only the API service. The frontend deploy is a no-op — recorded as “already delivered” for this content, not skipped and not re-run.
Selective delivery in depth
Standard CI deploys trigger on commit SHA: every push to main re-deploys everything. At monorepo scale this means 200 services re-deploying when 1 changed.aspect delivery uses content hashes instead:
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 extracting digest hashes from a gRPC execution log. This gives a stable, hermetic hash of each target’s actual build outputs — not the source inputs, not the git SHA.
Phase 2: State query
deliveryd (a Unix-socket HTTP server started automatically on Aspect Workflows CI runners) stores delivery history as (label, digest) → delivery event tuples, keyed by commit SHA + task name. Before dispatching, the task queries deliveryd to check whether each (label, hash) pair has already been delivered. If yes, the target is skipped. If no, it’s a delivery candidate.
Phase 3: Fused download and dispatch
Surviving candidates download their runfiles and dispatch as a single pipelined stream. Each target’s bazel run starts the moment its runfiles land on disk, rather than waiting for every download to complete first. The task drains build events concurrently with the download, so a slow target no longer blocks earlier-ready ones. Up to --max-parallelization deliveries run in parallel (defaulting to hardware thread count), and after each dispatch deliveryd records the result.
Why content hashes beat git SHAs
Git SHAs capture “what committed?”, not “what changed in the outputs?”. Two commits that produce identical binaries (e.g., a docs-only change alongside a code change) get different SHAs. Content hashes capture the actual artifact identity, so identical outputs are never 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 togit, embeds a timestamp, etc.) or a stamped action — the digest can’t be computed and the target would be silently skipped.
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:
- Make the action deterministic and cacheable (remove the
git/datecall, dropno-cachetags, etc.). - Tag it
no-remoteorlocalso Bazel runs it locally on every invocation and never blocks the digest lookup on a missing remote cache entry.
--warn-unresolved-digest:
WARN, leaves them undelivered, and exits zero. Prefer fixing the underlying uncacheable action — the warning path silently drops targets from delivery.
Configuration
Delivery mode
selective is the correct mode for production pipelines. always is useful for initial setup, debugging, or forced rollouts.
Resolving delivery targets
--query flag is a Bazel query expression. Any Bazel query syntax works: kind(...), attr(...), unions, intersections.
Forcing re-delivery
Dry run
Parallelism
Stamping and Bazel flags
Delivered binaries are stamped by default —--release-bazel-flag defaults to --stamp, so version-control info is embedded without any configuration. There are two separate flags for passing Bazel flags, and choosing the right one matters for change detection:
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”] — 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.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, reconfiguring the analysis — 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 by appending the suffix to the runner-derived --output_base, so its analysis stays warm across delivery runs without invalidating the cache the other jobs share. Only --output_base is made unique; --output_user_root stays shared, so the install base and the external-repository cache are reused rather than duplicated. (Delivery’s plain change-detection build stays on the shared server; its checksum phase, which injects an aspect, automatically uses its own server regardless of this flag.)
.aspect/config.axl:
.aspect/config.axl
--workflows:runner-output-base-suffix that applies to every task.
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.
Four DeliveryTrait hooks let you enrich, render, upload, and act on the manifest. The on-disk file and the CI artifact have separate renderers so you can ship JSON for tooling AND YAML/CSV for humans (or any other split) from the same run:
delivery_target(entry)— per-target enrichment (e.g. attach the OCI image digest your push rule wrote alongside the binary).render_manifest_file(manifest)— content of--manifest-file. Default: pretty-printed JSON.upload_manifest(manifest)— list of CI artifacts to upload. Default: one JSON artifact. Return multiple entries to split the manifest across files / formats; return[]to disable uploads.delivery_manifest(manifest)— end-of-task action (e.g. assemble an OCI layer in-task, post to an escrow registry, write an audit log).

