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 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 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.
These take no arguments and bracket the run:
  • 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. Unlike delivery_manifest, it doesn’t fire when the task exits early.
Each hook receives only the 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’s custom stays empty.
  • A dict: keys merge into entry["custom"].
  • Anything else: the task fails with an error, so typos surface.
Materialization guarantee: the target’s runfiles tree is on disk when the hook fires (the delivery build uses --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: 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, 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 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 only the OCI images and their digests:
.aspect/config.axl
The Jinja2 template filters to 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
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 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

  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 finishes.
  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 final status update posts; the GitHub status check and Buildkite annotation show one labeled link per uploaded artifact.
  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