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

# Generate it, then ship it

> Write a Gazelle extension in Starlark that encodes your deployment convention, then deliver the images it generates.

```shell theme={null}
git checkout step-7
```

Since step-1 every `cmd/*` package has shipped a `service.yaml` that nothing read:

```yaml title="cmd/server/service.yaml" theme={null}
name: server
port: 8080
replicas: 3
healthcheck: /healthz
resources:
  cpu: "500m"
  memory: 512Mi
```

Up to step-6 the file opened with a comment saying nothing read it yet. At this step something does, and the comment says so: `name:` becomes the image's `org.opencontainers.image.title` label and the push repository, `port:` becomes `exposed_ports`, and the remaining keys are there to look like a real deployment descriptor — nothing consumes them.

## The gap Gazelle can't fill

Gazelle already turns `.go` files into `go_library`, `go_binary` and `go_test`. Writing a Starlark extension to do that again would be busywork.

What Gazelle cannot know is **your repository's convention**. Here it is: *a package that ships a `service.yaml` is a deployable service, and every deployable service is packaged the same way.* That's the ten-odd targets of container boilerplate a platform team otherwise copy-pastes into every service and then maintains by hand.

`tools/gazelle/service_image.axl` encodes it. Four declarations per service:

| target | kind | |
| - | - | - |
| `<bin>_linux` | `go_cross_binary` | cross-compile for linux/amd64 |
| `<bin>_layer` | `tar` | that one static binary |
| `image` | `oci_image` | a from-scratch linux image |
| `push` | `oci_push` | publish to `ttl.sh` |

## Run it

The `step-7` commit already contains the generated targets — the BUILD files are part of the commit, the same as every other step — so the first thing `aspect gazelle` does here is prove it has nothing to do:

```shell theme={null}
aspect gazelle
```

```text theme={null}
✅ All BUILD files are up to date.
```

That is the idempotence claim, arriving before the generation rather than after it. To watch the generation itself, throw the four targets away and ask for them back:

```shell theme={null}
git restore --source=step-6 -- cmd/*/BUILD.bazel
aspect gazelle
git diff step-6 -- cmd/server/BUILD.bazel
```

`git restore`, not `git checkout step-6 --`, on purpose: `checkout` with a tree-ish writes the old files into the index as well as the working tree, and the `git checkout -- .` every other page ends with would then quietly hand you step-6's BUILD files back and un-generate everything you are about to watch. `restore` touches the working tree only.

That is also why the diff names `step-6` explicitly. The index still holds step-7, and what gazelle just regenerated is byte-identical to it, so a plain `git diff` compares the file against itself and prints nothing. Naming the commit asks the question you actually mean: what did generation put here that step-6 did not have?

```diff theme={null}
-load("@rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
+load("@rules_go//go:def.bzl", "go_binary", "go_cross_binary", "go_library", "go_test")
+load("@rules_oci//oci:defs.bzl", "oci_image", "oci_push")
+load("@tar.bzl", "tar")
```

```diff theme={null}
+go_cross_binary(
+    name = "server_linux",
+    platform = "@rules_go//go/toolchain:linux_amd64",
+    target = ":server",
+)
+
+tar(
+    name = "server_layer",
+    srcs = [":server_linux"],
+    include_runfiles = False,
+)
+
+oci_image(
+    name = "image",
+    architecture = "amd64",
+    entrypoint = ["/cmd/server/server_linux"],
+    exposed_ports = ["8080/tcp"],
+    labels = {
+        "org.opencontainers.image.title": "server",
+    },
+    os = "linux",
+    tars = [":server_layer"],
+)
+
+oci_push(
+    name = "push",
+    image = ":image",
+    remote_tags = ["1h"],
+    repository = "ttl.sh/server",
+)
```

That is the `cmd/server` hunk; `cron` and `worker` are the same shape with their own names and ports. Three services, no hand-editing, and what comes back is byte-identical to what the commit held. `exposed_ports` came from `port:` in the YAML and the label from `name:` — those two keys are the only ones the extension reads. `replicas`, `healthcheck` and `resources` are there so the file looks like a real deployment descriptor; nothing in this repository consumes them.

## Two decisions worth reading

**`go_cross_binary`, not the `go_binary` directly.** On a Mac the host toolchain builds a darwin binary, and a darwin binary inside a `linux` image builds and pushes perfectly happily while being completely unrunnable. The cross rule pins the layer to the platform the image claims.

**No `base`.** A statically linked Go binary needs no libc, no shell and no certificate bundle, so `os` and `architecture` say everything a runtime needs. That means the *image* pulls nothing from a registry: no base layer, and no digest to go stale.

<Note>
  It does not mean this step is offline. Step 7 is the only step that reaches the network outbound, and it does so twice. Checking out `step-7` adds three `bazel_dep`s — `aspect_gazelle_prebuilt`, `rules_oci` and `tar.bzl`, with a 94-line delta to `MODULE.bazel.lock` — all fetched the first time you build here. And `aspect delivery` below pushes three images to `ttl.sh`.

  Once those dependencies are on disk, the push is the only thing left that needs a network. If the room's wifi gives out, `bazel build //cmd/server:image` still proves everything except it: cross-compile, tar, assemble the image, all locally.
</Note>

## Set your prefix — this one matters

`ttl.sh` is anonymous, which is what makes it usable here. It also means **one global namespace with no ownership**: whoever pushes `ttl.sh/server:1h` last owns that tag for everybody.

Add one line to the root `BUILD.bazel`, with something unique to you:

```python theme={null}
# gazelle:service_image_prefix your-initials
```

```shell theme={null}
aspect gazelle
grep -h 'repository =' cmd/*/BUILD.bazel
```

```text theme={null}
    repository = "ttl.sh/your-initials/cron",
    repository = "ttl.sh/your-initials/server",
    repository = "ttl.sh/your-initials/worker",
```

One directive in the root file reached all three packages — properties are inherited by subpackages. It is a directive rather than a `service.yaml` key on purpose: a committed YAML value would be identical for everyone, which is the collision it exists to prevent.

## Ship it

```shell theme={null}
aspect delivery --task:name delivery --mode=always --track-state=false
```

Three flags, because `aspect delivery` is built for CI and you are not on CI:

* **`--task:name delivery`** names this *invocation* of the task. Every task takes the flag, and delivery uses it as the key its change detection records against; it defaults to `<kind>-<suffix>`, so naming it explicitly is what makes the key the same on every run. Hence the apparent stutter — the flag is naming the invocation, not the task.
* **`--mode=always`** skips change detection and runs every target the query resolved.
* **`--track-state=false`** because the state backend delivery records into is an Aspect Workflows service. Change detection cannot decide anything without state, so `--mode=selective --track-state=false` is rejected outright, which is what forces `--mode=always`.

```text theme={null}
Delivery:
  Mode: always
  State: (untracked — --track-state=false)
  Host: bk
  Commit: -
  Prefix: delivery (set by --task:name)
  URL: -
  Flags: [...]

Found 3 targets to deliver:
  - //cmd/cron:push
  - //cmd/server:push
  - //cmd/worker:push

  Delivering //cmd/cron:push (FORCED)...
2026/10/10 08:16:22 pushed blob: sha256:1b92a0a10301a77e0457cf1f81e71b2fbef832bf6b4a349f53fcd795f8ccc679
2026/10/10 08:16:23 pushed blob: sha256:5fd48ee504d18bf4c531f56052f0e2ab9d4a9cecea1984cabe1beaa6a863add0
2026/10/10 08:16:23 ttl.sh/your-initials/cron@sha256:ddeccb09...: digest: sha256:ddeccb09... size: 476
  ... four more crane lines per service ...

  TARGET             STATUS       DURATION  KEY                    BUILD URL
  //cmd/cron:push    OK (FORCED)  8.0s      -                      -
  //cmd/server:push  OK (FORCED)  9.8s      -                      -
  //cmd/worker:push  OK (FORCED)  7.3s      -                      -

Summary: 3 delivered, 0 skipped, 0 pending, 0 failed  (26.0s total)
```

Most of what scrolls past is `crane` narrating each blob it uploads — about twenty lines, trimmed here. `KEY` and `BUILD URL` are empty because both come from the CI state you just turned off.

`config.axl` gained one line — `ctx.tasks["delivery"].args.query = 'kind("oci_push rule", //...)'` — which is what makes a bare `aspect delivery` mean "publish every image the extension generated."

## Verify it is yours

Compare the registry's digest against the one you built:

```shell theme={null}
curl -sS -D - -o /dev/null -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
  https://ttl.sh/v2/your-initials/server/manifests/1h | grep -i docker-content-digest
jq -r '.manifests[0].digest' bazel-bin/cmd/server/image/index.json
```

`-D -` dumps the response headers to stdout and `-o /dev/null` throws the body away, because the registry serves the digest as a header rather than in the manifest. The second line reads the image you built: `bazel-bin/` is a symlink into Bazel's output base, and `index.json` is the OCI index `oci_image` wrote there.

They match. That is the check to trust — not `docker pull`, which needs Docker and tells you less.

<Note>
  Your digest will probably be **identical** to your neighbour's, and that is correct. The prefix changes where the image is pushed, not what it contains; identical source produces an identical image. What the prefix guarantees is that your tag keeps serving your content after someone else pushes theirs.
</Note>

Your container expires on its own in an hour. No account was involved at any point.

## Your turn

Add a `service.yaml` to a package that has no `func main()` — `lib/api`, say — and run `aspect gazelle`. It fails by name, and the reason is worth reading: `go_cross_binary` accepts a `go_library`, so without that check you would get an image that builds successfully and ships a stub.

It also fails *indirectly*, which is the other half of the lesson — read the Warning below before you believe what `aspect gazelle` tells you about your working tree.

<Warning>
  There are two ways to break the extension and they behave differently.

  A **runtime** failure — a `fail()`, which is what the exercise above triggers — prints its message in the `Diff` stage. Then `aspect gazelle` reports `Apply error` and something about your working tree changing. Your working tree is fine: Orion wrote its error to stdout, so the task saw output that isn't a patch. The real message is a few lines up.

  A **parse error** in `service_image.axl` is worse, because the one thing it does print points at the wrong file. The plugin never loads, so the directive you added to the root `BUILD.bazel` is no longer recognised by anything, and that is the only message you see:

  ```text theme={null}
  → 📋 Diff · Running gazelle (-mode=diff)
  aspect-gazelle: /path/to/repo/BUILD.bazel: unknown directive: gazelle:service_image_prefix

  → 📦 Apply · Applying gazelle patch (0 files)
  ...
  → ❌ Failed gazelle task (exit code 128) in 469ms · Apply error
  ```

  Your `BUILD.bazel` is fine. The directive is unknown because the extension that declares it failed to parse, and the real diagnostic went to stdout where nothing surfaces it. Run the binary directly to see it:

  ```shell theme={null}
  bazel run //tools/gazelle:gazelle
  ```

  ```text theme={null}
  Failed to load orion plugin tools/gazelle/service_image.axl:317:8: got is, want newline
  ```

  Iterate that way while you are editing the extension.
</Warning>

Next: hand all of this to an agent.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.