delivery_manifest— queried Bazel for deliverable targets, hashed their outputs, and recorded a manifest in Redis.delivery— read the manifest from Redis and ranbazel runon each new target, signing completed deliveries to prevent repeats.
aspect delivery collapses both into a single command, shaped by three orthogonal flags:
--mode={selective,always}(defaultselective) — the delivery model.selectiveuses change detection: only runs candidates that haven’t already been delivered for the--task:name[:--salt]prefix.alwaysskips change detection and treats every resolved target as a delivery candidate (closest analogue to the legacy “always deliver” model —--force-targetis redundant).--dry-run(default off) — print what would be delivered, don’t actually run anything.--track-state={true,false}(defaulttrue) — whether to track delivery state across runs (record what’s been delivered, query before re-delivering). Required (true) when--mode=selective— selective delivery has no meaning without persisted state. Set tofalseoutside Aspect Workflows CI runners (where the state backend isn’t yet available).
This migration only affects how delivery is triggered and tracked. The delivery targets themselves (
bazel run-able rules tagged deliverable) do not need to change.| Combination | Behavior | Prerequisites |
|---|---|---|
--mode=selective (default) | selective; runs candidates; records state | state backend, remote cache |
--mode=selective --dry-run | selective preview; records digests for future runs; no run | state backend, remote cache |
--mode=always | runs every target; records digests + deliveries | state backend, remote cache |
--mode=always --dry-run | always preview; records digests; no run | state backend, remote cache |
--mode=always --track-state=false | runs every target; nothing recorded; no digests computed | none |
--mode=always --dry-run --track-state=false | best-effort preview without state tracking; nothing recorded; no run | remote cache (optional — degrades to a digest-less list if missing) |
--mode=selective --track-state=false (any --dry-run) | invalid — selective delivery requires state | — |
--mode=selective --track-state=false is rejected — selective delivery has no meaning without persisted state. For previews outside Aspect Workflows CI runners use --mode=always --dry-run --track-state=false; to actually run targets without state tracking use --mode=always --track-state=false. See Requirements below for the constraints on each prerequisite.What determines “needs delivery”
Legacy YAML-configured tasks: a target was new if its Bazel output hash had never been recorded in Redis (SETNX). The salt could be customized via salt_envs.
Aspect CLI: change detection considers a target new if the combination of its action digest (a stable hash of the target’s action graph) and the change-detection prefix has not been recorded by the state backend. The prefix is --task:name alone, or --task:name:--salt when both are provided.
Because the action digest is computed by the remote cache protocol — not by hashing local output files — it is stable across machines and rebuild attempts. Two runners producing bit-identical outputs derive the same digest, so change detection correctly skips the second delivery.
Requirements
The combination matrix above shows which prerequisites apply per invocation. Two notes worth highlighting:- The remote cache derives action digests via Bazel’s gRPC log. It’s required whenever digests are recorded or surfaced in
--dry-runoutput — i.e. everywhere except--mode=always --track-state=false(which builds and runs targets directly without computing digests). For--mode=always --dry-run --track-state=falsethe cache is optional: if configured, digests appear in the preview; if not, the preview prints aNOTEand lists candidates without digests. - The state backend records what’s been delivered. It’s started automatically on Aspect Workflows CI runners. Pass
--track-state=falseoutside Aspect Workflows CI runners.
What changed
| Area | YAML-configured tasks | Aspect CLI tasks |
|---|---|---|
| Invocation | rosetta run delivery_manifest then rosetta run delivery (two-step pipeline reading/writing a manifest in Redis) | aspect delivery (single command) |
| Targets (query) | deliverable: '<query>' (query expression) | --query '<query>' — e.g. aspect delivery --query 'attr("tags", "deliverable", //...)' |
| Targets (label list) | deliverable: [//foo, //bar] | pass labels positionally — e.g. aspect delivery //foo //bar |
Stamp flags (--stamp, --workspace_status_command) | stamp_flags: [<flags>] | Use --release-bazel-flag=<flag> — not --bazel-flag. Release flags apply only to the final delivery build, so they don’t shift the change-detection digest. --release-bazel-flag defaults to --stamp, so stamping is already on; add --release-bazel-flag=--workspace_status_command=<path> for a custom status script. See Stamp and release-only flags. |
| Other Bazel flags | — | Repeat --bazel-flag=<flag> for each entry — e.g. --bazel-flag=--jobs=100. These apply to every build phase, including change detection, so use them only for flags that don’t change outputs. A release --config that enables stamping is non-deterministic — pass it as --release-bazel-flag=--config=release instead. |
| Salt | salt_envs: [A, B] (list of env var names; values concatenated automatically) | --salt "$A:$B" — compose the string yourself and pass a single value. Appended to --task:name to scope change detection. |
| Dry-run / manifest-only | manifest_only: true | --dry-run |
| Forced re-delivery (per-rule) | only_on_change: false on a rule (disable change detection for every target the rule produces) | --force-target=//foo:bar per target (granularity shifts from rule → specific label), or --mode=always to disable change detection for every target in the invocation |
| Forced re-delivery (env var) | ASPECT_WORKFLOWS_DELIVERY_TARGETS=//foo,//bar (comma-separated) | ASPECT_WORKFLOWS_DELIVERY_FORCE_TARGETS="//foo //bar" (whitespace-separated) — merged with any --force-target=... flags. Repeating --force-target=//foo --force-target=//bar works too. Each label must already be in the resolved delivery set (from --query / positional args): force-target only flips the change-detection skip; unmatched labels hard-fail at startup. |
| Branch filter | condition: { branches: [...] } | run aspect delivery only from the relevant CI branch step |
| Tag filter | condition: { tags: [...] } | run aspect delivery only from the relevant CI tag step |
| Limit query | limit_query | not needed — scope the --query expression directly |
| State store | redis_url / redis_use_tls | not applicable (the state backend replaces Redis) |
| Trigger | auto_deliver: true | not applicable — calling aspect delivery is itself the trigger |
| Workspaces | workspaces: top-level list | not applicable — cd <dir> in your CI config and run aspect delivery from there |
Examples
The CI examples below omit
--commit-sha and --build-url: both are auto-detected from CI environment variables (GITHUB_SHA / BUILDKITE_COMMIT / CIRCLE_SHA1 / CI_COMMIT_SHA for the SHA; the corresponding run/build URL for the build URL). Pass them explicitly only when you need to override the detected values — for instance, if your CI host isn’t recognized or you want to attribute the delivery to a different commit. On GitHub Actions pull_request events, detection picks the PR-head SHA rather than the synthetic merge SHA in GITHUB_SHA. On GitHub Actions, the build URL is upgraded from the run page (/actions/runs/<id>) to the specific job page (/actions/runs/<id>/job/<job_id>) when the Aspect Workflows GitHub App is installed and the ASPECT_API_TOKEN in use has a role granting actions: read (e.g. GitHub CI or GitHub Token: actions — see GitHub token roles and scopes). If not, the run URL is used.Query-based delivery on main
Before —.aspect/workflows/config.yaml:
Multi-condition rules (branches and tags)
Before —.aspect/workflows/config.yaml:
Dry-run / manifest-only mode
Before —.aspect/workflows/config.yaml:
Always deliver (--mode=always)
--mode=always disables change detection entirely and runs every resolved target. It’s the closest analogue to the legacy only_on_change: false behavior. Pair with --track-state=true (the default) on CI to record digests and deliveries — that way a subsequent --mode=selective run sees the up-to-date state. Pair with --track-state=false outside Aspect Workflows CI runners (local development, non-Aspect-Workflows CI) to skip both state tracking and the remote-cache requirement entirely.
Common use cases:
-
Backfills and cache-invalidation events in CI, where every matching target should re-deliver regardless of change-detection state. Equivalent to listing every target in
--force-target=…but in one flag, and the deliveries are still recorded: -
Local testing of forced-delivery flows. Preview isn’t enough; you want to actually invoke each target on your machine. No remote cache, no state tracking:
-
Adopting
aspect deliverybefore a remote cache is provisioned. Run with--mode=always --track-state=falseinitially, then switch to--mode=selectiveonce a remote cache and a state backend are wired up.
Break-glass forced re-delivery
There are two break-glass paths: force a specific target (or set of targets), or force everything in scope.Force everything (--mode=always)
When you want every resolved target re-delivered — backfills, cache-invalidation events, post-incident catch-up — pass --mode=always instead of enumerating each label. It skips change detection entirely while still recording the resulting deliveries (so the next --mode=selective run sees them):
--mode=always.
Force specific targets (parameterized CI job)
The legacyASPECT_WORKFLOWS_DELIVERY_TARGETS env var let users pick the target list at job-trigger time from a CI parameter field. The new aspect delivery accepts the same pattern via ASPECT_WORKFLOWS_DELIVERY_FORCE_TARGETS (whitespace-separated labels), which is merged with any --force-target flags. Wire your CI’s parameter input straight to that env var:
Stamp and release-only flags
aspect delivery has two flags for passing Bazel flags, and the difference matters for change detection:
| Flag | config.axl arg | Applies to | Use for |
|---|---|---|---|
--release-bazel-flag | release_bazel_flags | the final delivery build only (phase 3) | anything that adds non-determinism to release artifacts — --stamp, --workspace_status_command=<path>, and any --config that turns those on |
--bazel-flag | bazel_flags | every build phase, including the change-detection digest phases | only flags you’re certain don’t change outputs run-to-run — e.g. --jobs=100, --remote_cache=<uri> |
stamp_flags list mapped to the delivery build, so it migrates to release_bazel_flags:
Before — .aspect/workflows/config.yaml:
.aspect/config.axl so every aspect delivery invocation picks it up:
.aspect/config.axl
release_bazel_flags defaults to ["--stamp"], so delivered binaries are stamped out of the box — you only need to set it to add a --workspace_status_command or other release-only flags, or to opt out with --release-bazel-flag=--nostamp. Assigning a new list replaces the default, so include --stamp in your list if you still want stamping.--config usually pulls in stamping (e.g. build:release --stamp --workspace_status_command=...), so it belongs in release_bazel_flags — that applies it only to the final delivery build and keeps the volatile values out of the change-detection digest:
.aspect/config.axl
bazel_flags for flags you’ve confirmed don’t change outputs run-to-run (e.g. --jobs, --remote_cache); those apply to every phase so change detection and the final build agree.
Or pass either flag per invocation on the CLI when different delivery jobs need different flags (e.g. a release-only aspect delivery step alongside a default one):
Salted change detection
The legacysalt_envs list mixed environment variable values into the Redis state key so that changing any of them would re-trigger delivery. The new --salt flag serves the same purpose — pass the concatenated values yourself.
Changing the salt invalidates all prior change-detection state for the given
--task:name. Every target will be re-delivered on the next run. Pick salt sources that change only when you actually want a full re-delivery (e.g., a config version, not the current timestamp)..aspect/workflows/config.yaml:
Preview without state tracking (--mode=always --dry-run --track-state=false)
For environments where the state backend isn’t available — local development machines, non-Aspect-Workflows CI, or anywhere ASPECT_WORKFLOWS_DELIVERY_API_ENDPOINT is not set — combine --mode=always with --dry-run --track-state=false. Nothing is recorded or queried, and no targets are run.
DRY-RUN, scoped by the same --task:name[:--salt] change-detection prefix you’d use in CI. If a remote cache is configured, action digests are computed and shown alongside each candidate — useful for verifying digest stability or sanity-checking your setup. If no remote cache is configured, the preview prints a NOTE and falls back to listing candidates without digests; everything else still works. The other combination tolerant of a missing remote cache is --mode=always --track-state=false (which actually runs targets, also without computing digests); every other mode needs digests for correctness.
Key behavioral differences
| Area | YAML-configured tasks | Aspect CLI tasks |
|---|---|---|
| Pipeline | Two tasks (manifest → delivery) | Single command |
| State store | Redis | Built-in state backend on Aspect Workflows CI runners |
| Hash source | Bazel output file hash | Remote cache action digest |
| Branch/tag conditions | Declared in .aspect/workflows/config.yaml | Handled by CI script |
| Parallelism | Sequential by default | --max-parallelization flag |
| Forced delivery | ASPECT_WORKFLOWS_DELIVERY_TARGETS env var or only_on_change: false | --force-target flag, ASPECT_WORKFLOWS_DELIVERY_FORCE_TARGETS env var, or --mode=always |

