Skip to main content
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 key. 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: Dispatch Surviving candidates download their runfiles, then bazel run each target in parallel (up to --max-parallelization goroutines, defaulting to hardware thread count). 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.

Configuration

Delivery mode

aspect delivery --mode=selective --task-key delivery   # default: skip unchanged
aspect delivery --mode=always --task-key delivery       # always deliver all resolved targets
selective is the correct mode for production pipelines. always is useful for initial setup, debugging, or forced rollouts.

Resolving delivery targets

aspect delivery --query='kind(container_push, //services/...)' --task-key delivery
The --query flag is a Bazel query expression. Any Bazel query syntax works: kind(...), attr(...), unions, intersections.

Forcing re-delivery

aspect delivery --force-target=//services/api:push --task-key delivery
Repeatable. Forces the listed targets to deliver even if their content hash matches a prior delivery. Useful for rollbacks or emergency re-deploys.

Dry run

aspect delivery --dry-run --task-key delivery
Runs phases 1 and 2 (hash extraction, state query) without dispatching. Prints which targets would be delivered. Useful for validating your query and change-detection setup before the first real deploy.

Parallelism

aspect delivery --max-parallelization=4 --task-key delivery
Default: all hardware threads. Set lower if your deploy targets have rate limits or serial ordering requirements.

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:
FlagApplies toUse for
--release-bazel-flagthe final delivery build only--stamp, --workspace_status_command=<path>, and any flag — including a --config — that adds non-determinism to release artifacts
--bazel-flagevery build phase, including the change-detection digestonly flags you’re certain don’t change outputs run-to-run — e.g. --jobs=100, --remote_cache=<uri>
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
def config(ctx: ConfigContext):
    # Release-only flags — final delivery build only. Anything that adds
    # non-determinism to artifacts goes here, including a stamping --config.
    ctx.tasks["delivery"].args.release_bazel_flags = [
        "--stamp",
        "--workspace_status_command=tools/bazel/workspace_status.sh",
    ]
    # Applied to every phase, including change detection — reserve for flags
    # confirmed not to change outputs (e.g. --jobs, --remote_cache).
    ctx.tasks["delivery"].args.bazel_flags = ["--jobs=100"]
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.
Or pass either flag per invocation on the CLI. See Stamp and release-only flags in the migration guide for CI examples.

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).
See the Customize the delivery manifest guide for the manifest schema, the hook signatures, and end-to-end examples.

CI examples

on:
  push:
    branches: [main]

jobs:
  delivery:
    runs-on: [self-hosted, aspect-workflows, aspect-default]
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@v6
      - uses: aspect-build/setup-aspect@c22a8f64fb38f82f59ce809cd7eb9f8ae096da44 # v2026.23.2
        with:
          aspect-api-token: ${{ secrets.ASPECT_API_TOKEN }}
      - run: >
          aspect delivery
          --task-key delivery
          --query='kind(container_push, //services/...)'