cmd/* package has shipped a service.yaml that nothing read:
cmd/server/service.yaml
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:
Run it
Thestep-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:
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?
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.
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_deps — 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.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:
service.yaml key on purpose: a committed YAML value would be identical for everyone, which is the collision it exists to prevent.
Ship it
aspect delivery is built for CI and you are not on CI:
--task:name deliverynames 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=alwaysskips change detection and runs every target the query resolved.--track-state=falsebecause the state backend delivery records into is an Aspect Workflows service. Change detection cannot decide anything without state, so--mode=selective --track-state=falseis rejected outright, which is what forces--mode=always.
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:-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.
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.
Your turn
Add aservice.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.
Next: hand all of this to an agent.
