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 your hooks 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.
DeliveryTrait hooks are set in .aspect/config.axl. These customize what the manifest carries and what happens with it:
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.
delivery_start(): fires once at the start of the run, before any target is resolved or delivered.delivery_end(): fires once after every target has been dispatched and the summary printed. Unlikedelivery_manifest, it doesn’t fire when the task exits early.
entry or manifest dict, no context. When a hook needs ctx.std or ctx.template, define it inside config(ctx) so it closes over the ConfigContext, the same pattern task hooks use for shared state.
Manifest shape
The manifest is a stable, documented dict, safe to serialize to JSON and to consume from any downstream tooling:dry_run_build is how the document expresses --dry-run=build: with both it and dry_run true, the preview ran the build, so the per-target on-disk paths are populated. custom is reserved for hook-provided enrichment. Aspect never populates 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:
Return value:
None: no enrichment, the entry’scustomstays empty.- A
dict: keys merge intoentry["custom"]. - Anything else: the task fails with an error, so typos surface.
--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: 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, 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, with 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 only 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
Order of operations
- 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 finishes.
- 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 final status update posts; the GitHub status check and Buildkite annotation show one labeled link per uploaded artifact.
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 pipeline phases, and the CI examples.- Customize repro & fix suggestions: companion guide for the
Phases.repro_fix_suggestionhook. - AXL: what AXL is and what you can build with it.

