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.
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:
{
  "schema_version": 1,
  "ci_host":    "bk",
  "build_url":  "https://buildkite.com/aspect-build/silo-aws/builds/49508",
  "commit_sha": "abc123def456...",
  "prefix":     "delivery",
  "mode":       "selective",
  "dry_run":    false,
  "track_state": true,
  "counts":     { "ok": 3, "skip": 12, "fail": 0, "warn": 0, "pending": 0 },
  "deliveries": [
    {
      "label":      "//apps/api:image_push",
      "outcome":    "ok",
      "message":    "https://buildkite.com/aspect-build/silo-aws/builds/49508",
      "output_sha": "abc123...",
      "is_forced":  false,
      "custom":     { "image_digest": "sha256:cafef00d..." }
    },
    ...
  ]
}
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:
FieldTypeNotes
labelstrBazel target label, e.g. "//apps/api:image_push".
outcomestr"ok" / "skip" / "warn" / "fail" / "pending".
messagestrOutcome context: delivered-by URL on skip, error on fail, reason on warn, dry-run marker on pending.
output_shastrContent-hash digest used for change detection.
is_forcedbooltrue when --force-target re-delivered despite a prior delivery.
customdictEmpty dict you may write enrichment into (alternative to returning a dict).
entrypoint_pathstr | NoneAbsolute path of the executable the target ran. None on early-failure entries (build failure before runfiles materialized).
runfiles_dirstr | NoneAbsolute path of the target’s runfiles tree (e.g. …/image_push.runfiles), or None when the target has no runfiles.
runfiles_workspace_dirstr | NonePre-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_outputslist[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).
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
load("@aspect//traits.axl", "DeliveryTrait")

def _runfiles_path(entry, suffix):
    """`<runfiles_workspace_dir>/<package>/<target_name><suffix>` for a local label."""
    rwd = entry.get("runfiles_workspace_dir")
    if not rwd:
        return None
    label = entry["label"]
    if not label.startswith("//"):
        return None  # external repo deliverables need a different workspace path
    package, _, target_name = label.removeprefix("//").rpartition(":")
    if package:
        return "{}/{}/{}{}".format(rwd, package, target_name, suffix)
    return "{}/{}{}".format(rwd, target_name, suffix)

def _on_delivery_target(entry):
    if entry["outcome"] != "ok":
        return None
    digest_path = _runfiles_path(entry, suffix = ".digest")
    if not digest_path or not ctx.std.fs.exists(digest_path):
        return None
    return {
        "image_digest": ctx.std.fs.read_to_string(digest_path).strip(),
    }

def config(ctx: ConfigContext):
    ctx.traits[DeliveryTrait].delivery_target = _on_delivery_target
The resulting manifest entry now carries:
{
  "label": "//apps/api:image_push",
  "outcome": "ok",
  "custom": { "image_digest": "sha256:cafef00d..." },
  ...
}

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
def _on_delivery_target(entry):
    if entry["outcome"] != "ok":
        return None
    for path in entry.get("default_outputs", []):
        if path.endswith(".layer"):
            return {"layer_path": path}
    return None
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
def _on_delivery_target(entry):
    if entry["outcome"] != "ok":
        return None
    extras = {}

    digest_path = _runfiles_path(entry, suffix = ".digest")
    if digest_path and ctx.std.fs.exists(digest_path):
        extras["image_digest"] = ctx.std.fs.read_to_string(digest_path).strip()

    for path in entry.get("default_outputs", []):
        if path.endswith(".layer"):
            extras["layer_path"] = path
            break

    return extras if extras else None

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
_MANIFEST_YAML = """\
deliveries:
{% for d in deliveries %}
  - label: {{ d.label }}
    outcome: {{ d.outcome }}
    {% if d.custom.image_digest is defined %}image_digest: {{ d.custom.image_digest }}{% endif %}
{% endfor %}
"""

def _render_manifest_file(manifest):
    return ctx.template.jinja2(_MANIFEST_YAML, data = manifest)

def config(ctx: ConfigContext):
    ctx.traits[DeliveryTrait].render_manifest_file = _render_manifest_file
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
def _upload_manifest(manifest):
    # Disable uploads on a recognized CI host that already exposes the
    # manifest via its own surface (e.g. an in-house dashboard).
    return []

def config(ctx: ConfigContext):
    ctx.traits[DeliveryTrait].upload_manifest = _upload_manifest
Return one entry to swap format / name — the status surfaces will show a single link labeled by name:
.aspect/config.axl
def _upload_manifest(manifest):
    rendered = ctx.template.jinja2(_MANIFEST_YAML, data = manifest)
    return [{"name": "delivery-manifest.yml", "content": rendered}]

def config(ctx: ConfigContext):
    ctx.traits[DeliveryTrait].upload_manifest = _upload_manifest
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
_DELIVERY_IMAGES_YAML = """\
images:
{% for d in deliveries %}{% if d.outcome == "ok" and d.custom.image_digest is defined %}
  - label: {{ d.label }}
    image_digest: {{ d.custom.image_digest }}
    {% if d.custom.layer_path is defined %}layer_path: {{ d.custom.layer_path }}{% endif %}
{% endif %}{% endfor %}
"""

def _upload_manifest(manifest):
    return [
        {
            "name": "delivery-manifest.json",
            "content": json.encode(manifest, indent = 2),
        },
        {
            "name": "delivery-images.yml",
            "content": ctx.template.jinja2(_DELIVERY_IMAGES_YAML, data = manifest),
        },
    ]

def config(ctx: ConfigContext):
    ctx.traits[DeliveryTrait].upload_manifest = _upload_manifest
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
def _upload_manifest(manifest):
    # Ship JSON for tooling, YAML for human review, and a CSV of the
    # successful deliveries for the release-notes pipeline.
    successes = [d for d in manifest["deliveries"] if d["outcome"] == "ok"]
    csv = ["label,output_sha"] + [
        "{},{}".format(d["label"], d["output_sha"]) for d in successes
    ]
    return [
        {"name": "delivery-manifest.json", "content": json.encode(manifest, indent = 2)},
        {"name": "delivery-manifest.yml", "content": ctx.template.jinja2(_MANIFEST_YAML, data = manifest)},
        {"name": "successes.csv", "content": "\n".join(csv) + "\n"},
    ]

def config(ctx: ConfigContext):
    ctx.traits[DeliveryTrait].upload_manifest = _upload_manifest
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
def _on_delivery_manifest(manifest):
    """Post the manifest to an escrow registry once all targets are done."""
    if not manifest["deliveries"]:
        return  # early-exit path (validation / build failure) — nothing to post

    digests = [
        d["custom"]["image_digest"]
        for d in manifest["deliveries"]
        if d["outcome"] == "ok" and "image_digest" in d.get("custom", {})
    ]
    if not digests:
        return

    print("[delivery_manifest] posting {} digest(s) to escrow".format(len(digests)))
    # ... your in-process post / OCI layer assembly / audit-log write here ...

def config(ctx: ConfigContext):
    ctx.traits[DeliveryTrait].delivery_manifest = _on_delivery_manifest
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

FlagDefaultEffect
--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:
  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