aspect delivery invocation produces a delivery manifest — a structured record of every target’s outcome (ok / skip / warn / fail / pending), the resolved CI metadata (host, build URL, commit), and any per-target enrichment a customer hook attached. By default the manifest uploads as one JSON CI artifact (delivery-manifest.json) and surfaces as a labeled download link on the GitHub Status Check / Buildkite annotation. Pass --manifest-file=<path> to also write the manifest to disk.
Four DeliveryTrait hooks in .aspect/config.axl let you customize what the manifest carries and what happens with it. The on-disk file and the CI artifact have separate renderers so you can ship JSON for tooling AND YAML/CSV/etc. for humans (or any other split) from the same run:
delivery_target(entry)— fires once per delivery target with its terminal outcome plus on-disk paths. Return adictto enrich that target’scustomfield (e.g. attach an OCI image digest).render_manifest_file(manifest)— return the string written to--manifest-file. Default is pretty-printed JSON. Only fires when--manifest-fileis set.upload_manifest(manifest)— return the list of CI artifacts to upload. Default is one JSON artifact (delivery-manifest.json). Return[]to disable uploads; return multiple entries to split the manifest across files / formats.delivery_manifest(manifest)— fires once at end-of-task with the full structured manifest. Use for in-process actions like assembling an OCI layer, posting to an escrow registry, or writing an audit log.
Requires Aspect CLI v2026.23.46 or newer.
Manifest shape
The manifest is a stable, documented dict — safe to serialize to JSON and to consume from any downstream tooling:custom is reserved for hook-provided enrichment — Aspect will never populate it. Treat unknown fields as additive: Aspect may add new top-level or per-entry fields in future releases without bumping schema_version, as long as the additions are non-breaking.
Per-target enrichment
DeliveryTrait.delivery_target(entry) fires once per delivery target after its outcome is decided. The hook receives a dict with the manifest fields plus on-disk paths for reading sibling outputs the rule produced:
| Field | Type | Notes |
|---|---|---|
label | str | Bazel target label, e.g. "//apps/api:image_push". |
outcome | str | "ok" / "skip" / "warn" / "fail" / "pending". |
message | str | Outcome context: delivered-by URL on skip, error on fail, reason on warn, dry-run marker on pending. |
output_sha | str | Content-hash digest used for change detection. |
is_forced | bool | true when --force-target re-delivered despite a prior delivery. |
custom | dict | Empty dict you may write enrichment into (alternative to returning a dict). |
entrypoint_path | str | None | Absolute path of the executable the target ran. None on early-failure entries (build failure before runfiles materialized). |
runfiles_dir | str | None | Absolute path of the target’s runfiles tree (e.g. …/image_push.runfiles), or None when the target has no runfiles. |
runfiles_workspace_dir | str | None | Pre-joined <runfiles_dir>/<workspace> — the cwd bazel run uses, usually …/image_push.runfiles/_main under bzlmod. The canonical location for sibling files declared via data = [...]. |
default_outputs | list[str] | Every file in the target’s default output group. Includes files that aren’t in runfiles (e.g. an .layer blob a push rule writes alongside the binary). |
None— no enrichment, the entry’scustomstays empty.- A
dict— keys merge intoentry["custom"]. - Anything else — task fails fast with a clear error (so typos surface loudly).
--remote_download_outputs=toplevel + --build_runfile_links). Files declared via data = [...] are readable at runfiles_workspace_dir/<package>/<filename>. Files in default_outputs but not in runfiles also reach disk via --remote_download_outputs=toplevel; reach them by path from the default_outputs list.
Example — attach an OCI image digest
A common case: your push rule (e.g.rules_oci’s oci_push) writes a sibling .digest file alongside the binary. Read it from inside the runfiles tree and attach to custom:
.aspect/config.axl
Example — read a sibling default output NOT in runfiles
Some rules (rules_oci push, among others) write outputs that are in the target’s default output group but not reachable via runfiles. Scandefault_outputs by suffix instead:
.aspect/config.axl
default_outputs is the full list of files BES reported in the target’s default output group, with absolute filesystem paths. Files in runfiles are also here; the value is in scanning for ruleset-specific outputs that only live in default_outputs.
Combining patterns
The two patterns compose naturally — read the digest from runfiles, then scan default outputs for any sibling layer:.aspect/config.axl
Custom rendering
The on-disk file (--manifest-file) and the CI artifact have separate renderers — render_manifest_file and upload_manifest — so you can ship JSON for tooling and YAML for humans (or any other split) from the same run.
render_manifest_file — content of --manifest-file
DeliveryTrait.render_manifest_file(manifest) returns the string written to --manifest-file. Default (hook unset, or hook returns None) is pretty-printed JSON via json.encode(manifest, indent = 2).
Aspect ships with Jinja2 (ctx.template.jinja2); pair it with an inline template stored next to the hook so the template + interpretation live together:
.aspect/config.axl
data kwarg’s keys become top-level template variables — that’s why the template references deliveries, not data.deliveries. The is defined guard keeps minijinja’s strict-undefined check happy on entries whose custom dict doesn’t carry image_digest.
Set --manifest-file=delivery-manifest.yml so the on-disk file carries the right extension.
Contract:
- Must return a
stringorNone. Anything else fails the task with a clear error. - Returning
Nonefalls back to the default JSON rendering — useful for hooks that want to render conditionally (e.g. YAML on CI, JSON locally). - Only fires when
--manifest-fileis set. With no--manifest-file, this hook is never called. - Receives the same structured dict documented under Manifest shape.
upload_manifest — what gets uploaded as CI artifacts
DeliveryTrait.upload_manifest(manifest) returns the list of CI artifacts to upload. Each entry is a dict {"name": str, "content": str}: name is the artifact basename (controls extension), content is the rendered bytes. Default (hook unset, or hook returns None) uploads one JSON artifact (delivery-manifest.json, or the basename of --manifest-file when set).
Return [] to disable uploads entirely. The structured manifest dict is still passed to delivery_manifest regardless.
.aspect/config.axl
name:
.aspect/config.axl
.aspect/config.axl
ok entries that have an image_digest (set by the delivery_target enrichment hook from the earlier example), so the YAML side-artifact stays focused on the OCI subset even when the manifest also covers non-OCI deliverables (a sh_binary ops script, etc.). layer_path renders only when the enrichment hook also captured one — is defined is the guard for any optional custom field.
A more elaborate split — separate files per consumer:
.aspect/config.axl
name and in the order the hook returned. The same files are also reachable via the Artifacts browse link.
Contract:
- Must return a
listorNone. Anything else fails the task with a clear error. - Each entry must be a dict with non-empty
name(string) andcontent(string). - Returning
Nonefalls back to the single-JSON default; returning[]disables uploads. - Receives the same structured dict documented under Manifest shape.
End-of-task actions
DeliveryTrait.delivery_manifest(manifest) fires once at end-of-task with the full structured manifest — after every target’s delivery_target hook has merged its enrichment, and after the on-disk file is written and the CI artifact uploaded. Use for in-process actions that need the complete picture:
.aspect/config.axl
render_manifest_file / upload_manifest produced) so you don’t have to re-parse anything.
Early-exit paths fire too. The hook runs on every terminal path out of the task — validation failures, build failures, dispatch failures — not just successful runs. Inspect manifest["counts"] to distinguish a real delivery run from a startup error; an empty deliveries list means the task never reached dispatch.
CLI flags
| Flag | Default | Effect |
|---|---|---|
--manifest-file=<path> | "" (no file) | Write the manifest (rendered via render_manifest_file, or default JSON) to <path>. The structured dict is always passed to delivery_manifest regardless of this flag. |
Order of operations
Useful to know when composing hooks:- Each target’s outcome is decided.
delivery_target(entry)fires for that target; the returned dict merges intoentry["custom"].- After every target has been dispatched, the task reaches the terminal-emit path.
- The manifest is built from the recorded outcomes.
- When
--manifest-fileis set:render_manifest_file(manifest)is called (default JSON when unset or it returnsNone); the result is written to disk. upload_manifest(manifest)is called (default: one JSON artifact when unset or it returnsNone); each entry uploads as a CI artifact.- The terminal
task_update(final=True)fires — GitHub status check + Buildkite annotation snapshot artifact URLs here, so one labeled link is rendered per artifact uploaded in step 6. delivery_manifest(manifest)fires with the structured dict (not the rendered strings).
delivery_manifest running last means a hook that fails / raises won’t block the on-disk file, the artifact upload, or the terminal status surfaces — the manifest is committed to durable surfaces before any user code that might fail gets to see it.
See also
aspect deliverytask reference — task-level flags, the three pipeline phases, and the CI examples.- How to customize repro & fix suggestions — companion guide for the
TaskLifecycleTrait.repro_fix_suggestionhook. - Aspect Extension Language overview — what AXL is and why it’s typed Starlark.

