Skip to main content
Every 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 a dict to enrich that target’s custom field (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-file is 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.

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: Return value:
  • None — no enrichment, the entry’s custom stays empty.
  • A dict — keys merge into entry["custom"].
  • Anything else — task fails fast with a clear error (so typos surface loudly).
Materialization guarantee: the target’s runfiles tree is on disk when the hook fires (delivery’s phase 3 builds with --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
The resulting manifest entry now carries:

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. Scan default_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
The 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 string or None. Anything else fails the task with a clear error.
  • Returning None falls 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-file is 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
Return one entry to swap format / name — the status surfaces will show a single link labeled by name:
.aspect/config.axl
Return multiple entries to split the manifest across files / formats — for example, ship the full JSON for downstream tooling alongside a focused YAML side-artifact listing just the OCI images and their digests:
.aspect/config.axl
The Jinja2 template filters to just 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
Each uploaded file gets its own labeled link on the status surface (GitHub Status Check / Buildkite annotation), labeled by name and in the order the hook returned. The same files are also reachable via the Artifacts browse link. Contract:
  • Must return a list or None. Anything else fails the task with a clear error.
  • Each entry must be a dict with non-empty name (string) and content (string).
  • Returning None falls 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
The hook receives the structured dict (not any of the rendered strings 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

Order of operations

Useful to know when composing hooks:
  1. Each target’s outcome is decided.
  2. delivery_target(entry) fires for that target; the returned dict merges into entry["custom"].
  3. After every target has been dispatched, the task reaches the terminal-emit path.
  4. The manifest is built from the recorded outcomes.
  5. When --manifest-file is set: render_manifest_file(manifest) is called (default JSON when unset or it returns None); the result is written to disk.
  6. upload_manifest(manifest) is called (default: one JSON artifact when unset or it returns None); each entry uploads as a CI artifact.
  7. 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.
  8. 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