> ## Documentation Index
> Fetch the complete documentation index at: https://site.aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# Customize the delivery manifest

> Customize the Aspect CLI delivery manifest with DeliveryTrait hooks in config.axl to enrich targets, render files, upload artifacts, and act on output.

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:

```json theme={null}
{
  "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:

| 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).                                      |

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

```python title=".aspect/config.axl" theme={null}
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:

```json theme={null}
{
  "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:

```python title=".aspect/config.axl" theme={null}
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:

```python title=".aspect/config.axl" theme={null}
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:

```python title=".aspect/config.axl" theme={null}
_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](#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.

```python title=".aspect/config.axl" theme={null}
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`:

```python title=".aspect/config.axl" theme={null}
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:

```python title=".aspect/config.axl" theme={null}
_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](#example--attach-an-oci-image-digest)), 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:

```python title=".aspect/config.axl" theme={null}
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](#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:

```python title=".aspect/config.axl" theme={null}
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

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

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

* [`aspect delivery` task reference](/docs/cli/tasks/delivery) — task-level flags, the three pipeline phases, and the CI examples.
* [How to customize repro & fix suggestions](/docs/cli/guides/repro-fix-suggestions) — companion guide for the `TaskLifecycleTrait.repro_fix_suggestion` hook.
* [Aspect Extension Language overview](/docs/cli/overview#aspect-extension-language) — what AXL is and why it's typed Starlark.
