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
+24 -157
View File
@@ -10,9 +10,12 @@ on:
# pressure to merge in order to try something does not come from
# carelessness; it comes from `:dev` being unable to carry the build.
#
# All three images build on dev, deliberately: a `:dev` web image paired
# with a stale `:dev` ml or agent is a worse trap than no dev channel at
# all, since the mismatch only shows up as a runtime failure.
# BOTH images build on dev, deliberately: a `:dev` web image paired
# with a stale `:dev` agent is a worse trap than no dev channel at all,
# since the mismatch only shows up as a runtime failure. (It was three
# until #4311 — `fabledcurator-ml` was the same bytes as `fabledcurator`
# under a second name, and the stack that needed the second name is being
# collapsed onto the consolidated image.)
branches: [main, dev]
#
# NO tag trigger (milestone 318 step 2). A `v*` tag names a commit `main`
@@ -35,8 +38,8 @@ on:
# workflow is not shipped bytes, so it is in no artifact's path set. Putting
# it in one would re-version every artifact for a comment change.
#
# ONE input, not one per artifact. Forcing all three is cheap once the
# registry cache is warm (#3114), and three booleans is an interface nobody
# ONE input, not one per artifact. Forcing both is cheap once the registry
# cache is warm (#3114), and a boolean per artifact is an interface nobody
# remembers the meaning of.
workflow_dispatch:
inputs:
@@ -787,8 +790,8 @@ jobs:
#
# build-web additionally exposes this as `outputs.candidate`, which is
# what gates the `promote` job — a job's `if:` cannot read `env`, and
# one flag is enough because all three derive it from the same
# IS_REFRESH. ml and agent do not re-emit it; a second copy nothing
# one flag is enough because both jobs derive it from the same
# IS_REFRESH. build-agent does not re-emit it; a second copy nothing
# reads is the kind of thing that later reads as load-bearing.
if [ "${IS_REFRESH:-}" = "true" ]; then
echo "build_ref=$IMAGE:refresh-candidate" >> "$GITHUB_OUTPUT"
@@ -1523,18 +1526,19 @@ jobs:
# a FAILED gate blocks would have published unverified images while reporting
# success. Not running is not the same as passing.
#
# All three images promote TOGETHER, or none do. They are one stack: build.yml
# already refuses to publish a :dev web image beside a stale :dev ml, because
# the mismatch only shows up as a runtime failure. A refresh that published ml
# and withheld web would be that same trap, arrived at through the gate.
# BOTH images promote TOGETHER, or neither does. They are one stack:
# build.yml already refuses to publish a :dev web image beside a stale :dev
# agent, because the mismatch only shows up as a runtime failure. A refresh
# that published the agent and withheld web would be that same trap, arrived
# at through the gate.
#
# The gate covers the web image only (milestone 362 step 3 scoped it there),
# so ml and agent are being held to web's verdict rather than their own. That
# is deliberate and it is the conservative direction — they ship together, so
# the weakest evidence should govern all three — but it is not the same as
# having smoked them, and it should not be read as if it were.
# so the agent is being held to web's verdict rather than its own. That is
# deliberate and it is the conservative direction — they ship together, so
# the weakest evidence should govern both — but it is not the same as having
# smoked it, and it should not be read as if it were.
promote:
needs: [build-web, build-ml, build-agent, smoke-web]
needs: [build-web, build-agent, smoke-web]
# Only a refresh publishes through a candidate; a push writes its channel
# tag directly from the build. Reads the same reuse-step decision the build
# took, via a job output — a job's `if:` cannot see the `env` context.
@@ -1555,7 +1559,7 @@ jobs:
TAG=latest
FAILED=""
for NAME in fabledcurator fabledcurator-ml fabledcurator-agent; do
for NAME in fabledcurator fabledcurator-agent; do
REPO="bvandeusen/$NAME"
echo "promote: $REPO"
@@ -1616,144 +1620,7 @@ jobs:
echo "promote: idempotent." >&2
exit 1
fi
echo "promote: all three channel tags moved"
# NOT A BUILD. `fabledcurator-ml` is the SAME IMAGE as `fabledcurator` and
# has been since milestone 422 step 6 merged the ML layers into the one
# Dockerfile — this job built `file: Dockerfile, context: .`, byte for byte
# what build-web builds, and published it under a second name.
#
# So it was doing the whole build twice. Measured on run 7282, a 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."*
#
# It now publishes NOTHING OF ITS OWN. It re-tags the manifest build-web
# already pushed, so the second name goes on working for the operator's
# Swarm stack — which still references it — while CI stops building twice.
#
# The name is retired entirely in #4311, once that stack points its
# ml-worker service at `fabledcurator:latest` with `command: ["ml-worker"]`.
# Nothing here is load-bearing after that: the whole job goes, rather than
# this comment growing another paragraph.
build-ml:
# Was parallel with build-web. It cannot be any more — there is nothing to
# copy until build-web has decided what this run publishes. That is the
# cost of not building twice, and it is seconds: this job transfers no
# layers the runner does not already have.
needs: [build-web]
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
with:
ref: ${{ env.BUILD_REF }}
# Shallow is enough now. This job derives no version from history:
# it publishes no content, so it has no artifact identity of its own
# and `artifacts.sh revision ml` decides nothing here any more.
# See sign-extension's copy for why this guard exists.
- name: Guard — a scheduled run must have checked out main
if: env.IS_REFRESH == 'true'
run: |
set -eu
BRANCH=$(git rev-parse --abbrev-ref HEAD)
echo "schedule: HEAD is $BRANCH ($(git rev-parse --short HEAD))"
if [ "$BRANCH" != "main" ]; then
echo "schedule: expected main, got '$BRANCH'." >&2
echo "schedule: BUILD_REF was not honoured by the runner." >&2
echo "schedule: refusing to publish a channel tag from it." >&2
exit 1
fi
# Shell step rather than docker/login-action — see build-web's note on
# the shared action-cache race (#3118).
- name: Login to Forgejo registry
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
ACTOR: ${{ github.actor }}
run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin
- name: Point fabledcurator-ml at the image fabledcurator published
env:
SRC: git.fabledsword.com/bvandeusen/fabledcurator
DST: git.fabledsword.com/bvandeusen/fabledcurator-ml
BUILT_DIGEST: ${{ needs.build-web.outputs.digest }}
PUBLISHED_DIGEST: ${{ needs.build-web.outputs.published_digest }}
run: |
set -eu
# WHERE THE BYTES COME FROM. build-web either pushed a manifest this
# run, or it hit reuse — in which case the digest its channel tag
# already names is the right source, because that is what "hit"
# MEANS: the reuse step read this commit's fc.revision off that tag.
DIGEST="${BUILT_DIGEST:-}"
[ -n "$DIGEST" ] || DIGEST="${PUBLISHED_DIGEST:-}"
if [ -z "$DIGEST" ]; then
echo "alias: FAILED — build-web neither built an image nor" >&2
echo "alias: resolved a published one, so there is nothing for" >&2
echo "alias: fabledcurator-ml to be pointed at." >&2
exit 1
fi
# WHICH TAGS, mirroring exactly what build-web wrote for itself.
#
# A refresh writes the CANDIDATE tag and nothing else, so `promote`
# moves it to :latest after smoke-web passes. That keeps this name
# under the same gate it has today and needs no change to promote,
# which already loops over all three image names.
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
if [ "${IS_REFRESH:-}" = "true" ]; then
TAGS="refresh-candidate"
elif [ "${GITHUB_REF##*/}" = "main" ]; then
TAGS="latest c-${SHORT_SHA}"
else
TAGS="dev"
fi
# Pull, tag, push — deliberately NOT `imagetools create`.
#
# That wraps its source in an INDEX, and `.Image.Config.Labels` does
# not resolve through one (#3183, run 4751). Worse here than it was
# there: promote asks the registry for IMAGE manifest media types
# ONLY, so an index sitting at :refresh-candidate would fail its
# `test -n "$CT"` and break the weekly refresh for this name.
#
# MEASURED on run 7300, the first execution: the push moved ZERO
# layer bytes. Every blob came back either "Layer already exists" or
# "Mounted from bvandeusen/fabledcurator" — the registry does
# cross-repository blob mounts between two repos of one owner, so
# this copies a manifest and nothing else. 22s, against the ~2min
# the duplicate build cost (run 7282: build-ml 1m55s).
#
# The pushed manifest gets its OWN digest (b979b145… where the
# source was aca4c75…) because docker re-serialises it. Same layers,
# same config, same fc.revision; only the manifest bytes differ.
# Nothing compares the two, and promote reads whatever is at the tag
# — but it is worth saying, because the digests looking different is
# the first thing someone will notice and misread as a second build.
docker pull "$SRC@$DIGEST"
for t in $TAGS; do
echo "alias: $DST:$t -> $SRC@$DIGEST"
docker tag "$SRC@$DIGEST" "$DST:$t"
docker push "$DST:$t"
done
# Read it back. A push that reported success but left the tag
# elsewhere is exactly the silent-and-plausible failure this
# pipeline keeps producing, and the check costs one request.
for t in $TAGS; do
NOW=$(docker buildx imagetools inspect "$DST:$t" \
--format '{{ index .Image.Config.Labels "fc.revision" }}' 2>/dev/null || echo "")
echo "alias: $DST:$t now carries fc.revision=${NOW:-<none>}"
if [ -z "$NOW" ]; then
echo "alias: FAILED — $DST:$t has no readable fc.revision." >&2
echo "alias: The label resolves through a plain image manifest" >&2
echo "alias: and not through an index, so this means the tag is" >&2
echo "alias: index-wrapped — which breaks promote's refresh path." >&2
exit 1
fi
done
echo "promote: both channel tags moved"
build-agent:
runs-on: python-ci
container:
@@ -1961,8 +1828,8 @@ jobs:
#
# build-web additionally exposes this as `outputs.candidate`, which is
# what gates the `promote` job — a job's `if:` cannot read `env`, and
# one flag is enough because all three derive it from the same
# IS_REFRESH. ml and agent do not re-emit it; a second copy nothing
# one flag is enough because both jobs derive it from the same
# IS_REFRESH. build-agent does not re-emit it; a second copy nothing
# reads is the kind of thing that later reads as load-bearing.
if [ "${IS_REFRESH:-}" = "true" ]; then
echo "build_ref=$IMAGE:refresh-candidate" >> "$GITHUB_OUTPUT"