refactor: retire the fabledcurator-ml image name entirely (4311)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 1m37s
CI / integration (push) Successful in 2m11s
Build images / smoke-web (push) Successful in 42s
Build images / promote (push) Skipped

Operator, 2026-09-23: *"we don't need to keep this as I'll be collapsing my
stack to the same consolidated version that we're building here."*

That was the only thing holding the name up. `fabledcurator-ml` has been the
same bytes as `fabledcurator` since milestone 422 step 6, and 8152684 had
already stopped rebuilding it — this removes the name.

Gone: the whole `build-ml` job (137 more lines), its entry in promote's
`needs` and in promote's tag loop, `ML_PATHS` and the `ml)` case in
artifacts.sh, the `ml` artifact in test_artifact_paths, the image in
release_notes.py and its test, and the README row. `docker-compose.yml`'s
ml-worker service now runs `fabledcurator:latest` with `command:
["ml-worker"]` — the service still exists for anyone who wants lane
separation, it just no longer needs a second image name.

Prose that said "all three images" is now "both", except where it means
three VALUES (revision/version/epoch) or records what happened on 2026-08-30,
when there genuinely were three.

Published tags are left alone. Nothing new goes to that name; what is already
in the registry stays pullable, so a stack that has not moved yet keeps
running rather than losing its image mid-flight.

## A latent trap this made reachable, found by running the script

`artifacts.sh revision ml` did not fail. It printed the usage line to stderr
and answered with **the newest commit in the whole repository** — a
real-looking 12-char sha on stdout, exit 0.

`newest()` inlined the path set as `git log ... -- $(cmd_paths "$1")`, and
`usage` exits from the command SUBSHELL, so the substitution came back empty
and `git log HEAD --` had no pathspec left to filter by. The reuse check
would have compared that answer against a published label, missed, and
rebuilt on every push forever with nothing going red.

It could not be fixed inside `newest`, which was my first attempt: every
caller wraps it in a substitution too, so its `exit` also died in a subshell —
stdout went empty but the status stayed 0. The guard has to run in the main
shell, so it validates the artifact name at dispatch. `newest` keeps a
defensive `|| exit 2` for a future caller that reaches it another way, and
the comment says which of the two is the real check.

Latent while every name callers passed was valid. Removing `ml` from the set
is what made a name that used to work start taking the silent path, so the
test that pins it lands here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
2026-09-23 08:30:47 -04:00
co-authored by Claude Opus 5
parent f0595e43e6
commit ac70f2aadc
7 changed files with 100 additions and 192 deletions
+28 -2
View File
@@ -31,7 +31,6 @@ ROOT = Path(__file__).resolve().parent.parent
# artifact -> (dockerfile, build context relative to the repo root)
ARTIFACTS = {
"web": ("Dockerfile", ""),
"ml": ("Dockerfile", ""),
"agent": ("agent/Dockerfile", "agent"),
}
@@ -133,7 +132,7 @@ DERIVERS = [
# orphaned. Guarded for web too, since web bundles what the extension makes.
("extension/scripts/packaging.sh", ("extension", "web")),
# artifacts.sh decides the FC_VERSION baked into the web image (#3202).
# Web only, and deliberately: ml and agent ask this script for `revision`
# Web only, and deliberately: the agent asks this script for `revision`
# alone, so they are covered by the self-correcting path above, and the
# extension takes its version from packaging.sh. Milestone 318 step 5 is
# the worked instance — b3989d0 and 5771fd5 share revision fb2c4d5b80be
@@ -197,3 +196,30 @@ def test_files_that_never_reach_an_image_do_not_version_it(artifact, path):
f"image — it would re-version and rebuild {artifact} for a change it "
f"does not ship."
)
def test_an_unknown_artifact_fails_instead_of_answering():
"""It used to answer — with the newest commit in the whole repository.
`newest()` inlined the path set as `git log ... -- $(cmd_paths "$1")`.
`usage` exits from the command SUBSHELL, so an unknown name made the
substitution come back empty and `git log HEAD --` walked everything:
stdout got a real-looking 12-char sha, the exit code was 0, and the usage
line went to stderr where no caller reads it. A wrong answer shaped
exactly like a right one, which the reuse check would have compared
against a published label and quietly rebuilt on forever.
Latent while every name callers passed was valid. #4311 removed `ml` from
that set, which is what made a name that used to work start taking the
silent path.
"""
out = subprocess.run(
["sh", str(ROOT / "scripts" / "artifacts.sh"), "revision", "ml"],
capture_output=True, text=True, cwd=ROOT,
)
assert out.returncode != 0, (
f"an unknown artifact exited 0 and printed {out.stdout!r}"
)
assert not out.stdout.strip(), (
f"an unknown artifact printed {out.stdout!r} on stdout"
)
+5 -5
View File
@@ -195,13 +195,13 @@ def test_a_non_tag_ref_renders_but_refuses_to_claim_it_published():
body_of(out)
def test_the_rollback_refs_name_all_three_images():
"""Rule 145: `:c-<sha>` is the rollback unit, and the three images move
def test_the_rollback_refs_name_both_images():
"""Rule 145: `:c-<sha>` is the rollback unit, and both images move
together. A release listing only the web image sends an operator into a
rollback that leaves ml and agent on the newer build — the exact mismatch
build.yml builds all three on every push to avoid."""
rollback that leaves the agent on the newer build — the exact mismatch
build.yml builds both on every push to avoid."""
body = body_of(notes("HEAD"))
for image in ("fabledcurator", "fabledcurator-ml", "fabledcurator-agent"):
for image in ("fabledcurator", "fabledcurator-agent"):
assert f"bvandeusen/{image}:c-" in body, f"{image} missing from the rollback refs"