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
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:
+24
-157
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user