CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 5s
CI / frontend-build (push) Successful in 23s
extension / lint (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 22s
Build images / smoke-web (push) Successful in 30s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m17s
`fabledcurator-ml` has been the SAME IMAGE as `fabledcurator` since milestone 422 step 6 merged the ML layers into the one Dockerfile. `build-ml` was still running `file: Dockerfile, context: .` — byte for byte what `build-web` builds — and publishing the result under a second name. So CI did the whole build twice. Measured on run 7282, cold cache: build-web 1m54s, build-ml 1m55s, for identical output, plus a second push of a few hundred MB. Operator, 2026-09-22: *"please fix the CI so it doesn't do this superfluous work."* build-ml now publishes nothing of its own. It pulls the manifest build-web just pushed, re-tags it, and pushes that — so the second name goes on working for the operator's Swarm stack, which still references it, while CI stops compiling anything twice. 437 lines of duplicated build machinery go with it: its own buildx setup, its own reuse check, its own version derivation, its own tag repoint. It now `needs: [build-web]` rather than running in parallel. That is the one cost, and it is seconds: there is nothing to copy until build-web has decided what this run publishes, and the layers are already on the runner. Deliberately NOT `imagetools create`, which would have been the obvious one-liner. That wraps its source in an INDEX and `.Image.Config.Labels` does not resolve through one (#3183, run 4751) — and it would bite harder here than it did there, because `promote` asks the registry for IMAGE manifest media types ONLY, so an index at :refresh-candidate would fail its `test -n "$CT"` and break the weekly refresh for this name. Plain pull/tag/push keeps a plain manifest, which rule 40 also asks for. The step reads the tag back and fails if `fc.revision` stops resolving, so a future change that reintroduces index-wrapping says so instead of quietly costing the refresh. The tag list mirrors build-web's exactly, refresh included: a refresh writes :refresh-candidate alone and `promote` moves it to :latest after smoke-web passes. So this name keeps the gate it has today and `promote` needs no change — it already loops over all three image names. Source is build-web's `digest` when it built, else its `published_digest` on a reuse hit — the digest its channel tag already names, which is what a hit MEANS. With neither, the job fails with a reason rather than publishing something arbitrary. This does not retire the name; #4311 still does that, and is still gated on the operator's stack pointing ml-worker at `fabledcurator:latest` with `command: ["ml-worker"]`. Until then nothing of theirs changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR