diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index f70d5c9..802cd0c 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -1617,24 +1617,40 @@ jobs: 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: - # Not the triggering ref — see the `env:` block at the top. On a - # scheduled refresh this is `main`; on everything else it is the ref - # that fired, so this is a no-op on every ordinary path. ref: ${{ env.BUILD_REF }} - # Full history: this job derives its artifact's version from the - # commit its shipped files last changed in (milestone 313). A - # depth-1 clone cannot see that commit — it either derives a wrong, - # too-low value or finds nothing at all, and neither is a failure - # the build would otherwise notice. - fetch-depth: 0 + # 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 @@ -1650,66 +1666,6 @@ jobs: exit 1 fi - # --- derived values, one line (milestone 313) ------------------------ - # These stopped being shadow output at step 3. `revision` decides - # whether the build below runs at all and `version` is what the image - # reports about itself; the load-bearing steps each print only the one - # they use, so this is the only place the pair appears together. When a - # build is skipped, this is the line that says what the commit derived. - # - # Still diagnostic, so it still must not fail the build — no `set -e`, - # and every derivation falls back to UNAVAILABLE. A broken echo must - # never be the reason an image does not ship. - # - # What it should say: - # * a push touching only agent/ moves the agent and leaves web and ml - # STILL. If web moves, its path set is too wide. - # * a push touching only docs moves nothing. - # * a push touching the extension moves the extension AND web, since - # web bakes in the XPI. If web does not move, its set is too narrow: - # the reuse check hits, and the channel serves a web image bundling - # the PREVIOUS XPI while the freshly signed one is orphaned (#3156). - # * dev and main derive the same values for the same source. - - name: Report the derived artifact version - env: - # Diagnostic for the trigger normalisation. `refresh` is reported RAW - # as well as normalised, because the two disagreeing is the whole - # failure mode: a dispatch input whose type does not compare the way - # the expression assumes evaluates to false silently, and the only - # symptom is a refresh that quietly behaves like an ordinary push. - RAW_REFRESH: ${{ github.event.inputs.refresh }} - RAW_FORCE: ${{ github.event.inputs.force_build }} - run: | - set -u - echo "trigger: event=$GITHUB_EVENT_NAME IS_REFRESH='${IS_REFRESH:-}' BUILD_REF='${BUILD_REF:-}'" - echo "trigger: raw inputs refresh='${RAW_REFRESH:-}' force_build='${RAW_FORCE:-}'" - A=ml - V=$(sh scripts/artifacts.sh version "$A" 2>&1 || echo UNAVAILABLE) - R=$(sh scripts/artifacts.sh revision "$A" 2>&1 || echo UNAVAILABLE) - echo "derived: artifact=$A version=$V revision=$R sha=$GITHUB_SHA" - - - name: Determine tag - id: tag - run: | - # Mirrors build-web's tag list; see the comment there. - # POSIX-safe substring (the runner shell is dash/BusyBox sh, not - # bash — `${var:0:7}` errors with "Bad substitution"; cut works - # everywhere). Operator-flagged 2026-06-01 after first :c- - # main-push build failed at this step. - SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7) - # Mirrors build-web's tag list and its schedule handling; see - # the comments there. - if [ "${IS_REFRESH:-}" = "true" ]; then - echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT" - echo "channel=main" >> "$GITHUB_OUTPUT" - elif [ "${GITHUB_REF##*/}" = "main" ]; then - echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:latest,git.fabledsword.com/bvandeusen/fabledcurator-ml:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "channel=main" >> "$GITHUB_OUTPUT" - else - echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT" - echo "channel=dev" >> "$GITHUB_OUTPUT" - 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 @@ -1718,386 +1674,79 @@ jobs: ACTOR: ${{ github.actor }} run: echo "$TOKEN" | docker login git.fabledsword.com -u "$ACTOR" --password-stdin - # A REAL buildx builder, not the default `docker` driver (#3114, #3190). - # - # The default driver builds through the local dockerd. It cannot export a - # registry cache at all — which is why the agent rebuilds a ~6.3 GB CUDA - # + torch image from scratch whenever the runner's local cache is cold, - # measured at 9m26s against 7s warm. It is also #3190's leading suspect: - # after a registry-direct push it resolves image metadata against a local - # store the push never filled, and reports `No such image` on an image - # that published perfectly well three seconds earlier. - # - # These jobs run INSIDE a container against a mounted docker socket, so - # the buildkit container this starts is a SIBLING of the job container, - # not a child. That works over the socket mount; it had never been tried - # here before milestone 326 step 1. - - name: Set up buildx - uses: docker/setup-buildx-action@v3 - - # --- reuse-if-published (milestone 313, step 4) ---------------------- - # Does the image the channel tag already points at carry THIS commit's - # revision? If so the bytes this job would produce are already published - # and the build is pure waste: the remaining tags get repointed at that - # existing manifest instead, registry-side, in seconds. - # - # Keyed on an `fc.revision` LABEL rather than on a tag of its own - # (milestone 318 step 3). A tag would be a name minted per build that one - # thing reads — what rule 145 narrowed against — and would be prunable - # under the registry's keep_pattern (#3157), silently expiring the cache. - # A label rides inside a tag that has to exist anyway. - # - # An image with no such label reads as a miss and rebuilds. That is the - # migration, not a fault: labels cannot be backfilled, since the reuse - # path copies a manifest and config labels are not manifest annotations. - # Each artifact pays one rebuild, once. - # - # This is what stops a push that touched only `agent/` from rebuilding - # web and ml, and a merge to main from rebuilding what dev already built. - # - # The failure direction is deliberate. An inspect that errors for ANY - # reason — network, auth, a registry hiccup — reads as a miss and the - # build runs. Only a genuine 200 skips one, so there is no path here - # that skips a build that was actually needed; the worst case is paying - # for a build we could have avoided. - # - # BASE-IMAGE FRESHNESS: an artifact whose source stops moving stops - # picking up base-image updates. Milestone 318 removed the argument this - # used to need rather than answering it — with no version tags there is - # no immutable name a refresh could contradict, and rule 145 already - # allows a rebuild with different contents to republish a MOVING tag. - # So a refresh is just a build. A scheduled channel-only one is tracked - # separately (#3154); it does not belong in the push path. - - name: Is this content already published? - id: reuse + - name: Point fabledcurator-ml at the image fabledcurator published env: - IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-ml - CHANNEL: ${{ steps.tag.outputs.channel }} - # Empty on a push; the string "true" only from a workflow_dispatch - # that asked for it. `github.event.inputs` rather than the `inputs` - # context — release.yml already uses that form, and it is the one - # this runner is known to evaluate. Read through env rather than - # interpolated into the run block, same rule as release.yml's TAG. - FORCE: ${{ github.event.inputs.force_build }} - # A scheduled refresh has to bypass reuse by construction: it - # rebuilds the SAME source, so fc.revision always matches and the - # check would skip every refresh there has ever been. + 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 - DERIVED=$(sh scripts/artifacts.sh revision ml) - echo "revision=$DERIVED" >> "$GITHUB_OUTPUT" + # 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 - # The build clock, pinned to the same commit (#3265). Without it - # buildkit stamps the image config with the wall clock of the build, - # so identical layers republish under a new config blob and the - # channel tag gets a new manifest digest for no reason. Derived from - # `newest()` like revision and version, so all three name one commit - # and cannot drift apart. - echo "epoch=$(sh scripts/artifacts.sh epoch ml)" >> "$GITHUB_OUTPUT" - - # The moving tag for this channel. Which tag we ask IS the channel — - # that is why the revision needs no -main/-dev qualifier any more. - if [ "$CHANNEL" = "main" ]; then T=latest; else T=dev; fi - echo "channel_ref=$IMAGE:$T" >> "$GITHUB_OUTPUT" - - # WHERE THE BUILD PUBLISHES, which is not always the channel — and - # whether the channel then has to be written separately. + # WHICH TAGS, mirroring exactly what build-web wrote for itself. # - # On a push the build writes the channel tag directly: the bytes came - # from a commit, and a commit is the thing CI tests. Nothing to hold - # it behind. - # - # On the scheduled refresh it writes a CANDIDATE tag instead. A - # refresh rebuilds against freshly resolved base images, and the web - # image's runtime is a line of UNPINNED Debian packages (ffmpeg, - # libjpeg62-turbo, libpq5, megatools…) re-resolved on every build. - # Nothing in ci.yml can see that: its lanes run on ci-python:3.14 and - # install requirements.txt, and a base bump changes neither. So - # refreshed bytes have to be proven before :latest names them, and - # proving needs a moment between "built" and "published" to occupy. - # This is that moment; :latest goes on naming the build that works - # until something says otherwise. - # - # `:refresh-candidate` is one moving ref per image, overwritten in - # place, holding a build nobody is told to pull — the shape rule 145 - # already allows for :buildcache, not the per-build tag family that - # milestone 318 withdrew. - # - # Decided HERE, beside `hit`, for the reason the force/schedule - # branch below gives: one step decides what this job does. A - # condition derived independently could disagree with the tag the - # build actually wrote. - # - # 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 - # reads is the kind of thing that later reads as load-bearing. + # 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 - echo "build_ref=$IMAGE:refresh-candidate" >> "$GITHUB_OUTPUT" + TAGS="refresh-candidate" + elif [ "${GITHUB_REF##*/}" = "main" ]; then + TAGS="latest c-${SHORT_SHA}" else - echo "build_ref=$IMAGE:$T" >> "$GITHUB_OUTPUT" + TAGS="dev" fi - # Compare VALUES, never exit codes. Measured on buildx v0.36.1 - # (run 4732): a missing key returns an empty string and exits 0, so - # branching on the exit code would read "no label yet" as success. - # An unreachable tag also lands here as empty via the `|| echo`. - # Empty never equals a 12-char revision, so every uncertain case - # falls through to a build — the safe direction, with no special - # casing for it. + # Pull, tag, push — deliberately NOT `imagetools create`. # - # Read the SPECIFIC key. The map also carries whatever the base image - # set, and `org.opencontainers.image.version` sits right beside ours - # looking like a plausible answer (it reads 24.04 on the agent). - PUBLISHED=$(docker buildx imagetools inspect "$IMAGE:$T" \ - --format '{{ index .Image.Config.Labels "fc.revision" }}' \ - 2>/dev/null || echo "") - echo "reuse: $IMAGE:$T carries fc.revision=${PUBLISHED:-}; derived=$DERIVED" - if [ -z "$PUBLISHED" ] && docker buildx imagetools inspect "$IMAGE:$T" >/dev/null 2>&1; then - # The tag resolves but carries no readable label. Expected exactly - # once per artifact, during the migration onto labels. If it recurs - # every push, something is rewriting the channel tag as a manifest - # index — see the repoint step's note. - echo "reuse: NOTE $IMAGE:$T exists but has no readable fc.revision." - echo "reuse: NOTE Fine once, while migrating. Every push means the" - echo "reuse: NOTE tag is being index-wrapped and reuse is dead." - fi - - # FORCE is checked here rather than in the build step's `if:`, so - # that one decision drives everything downstream. The repoint step - # keys off `hit` too, and a force that bypassed only the build would - # leave the two disagreeing about what just happened. - if [ "${FORCE:-false}" = "true" ]; then - echo "hit=false" >> "$GITHUB_OUTPUT" - echo "reuse: force_build set — building regardless" - elif [ "${IS_REFRESH:-}" = "true" ]; then - echo "hit=false" >> "$GITHUB_OUTPUT" - echo "reuse: scheduled base refresh — building regardless" - elif [ -n "$PUBLISHED" ] && [ "$PUBLISHED" = "$DERIVED" ]; then - echo "hit=true" >> "$GITHUB_OUTPUT" - echo "reuse: already published — skipping the build" - else - echo "hit=false" >> "$GITHUB_OUTPUT" - echo "reuse: not published — building" - fi - - - name: Build and push ml image - # `id:` so the repoint step below can read `outputs.digest` — the - # manifest THIS run published, as opposed to whatever the channel tag - # happens to name by the time that step runs (#4290). - id: build - if: steps.reuse.outputs.hit != 'true' - # Read by buildx out of the ENVIRONMENT, not passed as a build-arg — - # it normalises the image config's `created` field and the history - # timestamps rather than being consumed by the Dockerfile. See #3265 - # and the reuse step's `epoch` output. - env: - SOURCE_DATE_EPOCH: ${{ steps.reuse.outputs.epoch }} - uses: docker/build-push-action@v5 - with: - context: . - # The merged image (milestone 422 step 6). `fabledcurator-ml` keeps - # publishing from it — same bytes under both names — because the - # operator's Swarm stack references fabledcurator-ml:latest and - # lives outside this repo. Dropping the name here would not break - # their deploy, it would freeze it silently at the last publish. - # Retiring the NAME is its own task, gated on that stack moving. - file: Dockerfile - push: true - # Re-resolve the FROM references against the registry instead of - # trusting whatever digest the cache was built against. This is the - # whole mechanism of the scheduled refresh (#3154): if the base tag - # moved, the FROM layer's cache key changes, every layer above it - # invalidates, and the image genuinely rebuilds. + # 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 the first real fire, run 4934 (#3265): when the base - # did NOT move, the build was ~13s with every content step CACHED — - # and the channel tag STILL got a new manifest digest, because - # buildkit stamps a fresh image config per run and republishes the - # identical layers under it. All three images moved that way on - # 2026-08-30 with nothing whatsoever changed in them. - # - # SOURCE_DATE_EPOCH (below) is the fix: pinned to the commit the - # content came from, the config is byte-identical across runs, so - # the manifest digest is too and the push is a registry no-op. A - # digest change means the content changed again, which is the only - # thing a digest is any use for. - # - # What `pull` does NOT catch either: a Debian package update inside - # the `apt-get install` layer while the base tag itself stands - # still. The official python/cuda images rebuild with those updates - # baked in, so this is a lag rather than a hole; closing it needs - # `no-cache: true`, which is a much larger version of the same - # churn #3265 is about. - # - # Only on the schedule. An ordinary push wants the cached base. - pull: ${{ env.IS_REFRESH == 'true' }} - # ONE tag, the channel's. Every other tag is written by the step - # below, registry-side. buildx here pushes the first tag to the - # registry and then re-pushes the rest through the DOCKER driver, - # out of a local image store a registry-direct build never filled — - # #3190, which cost `main` its :c- on 2026-08-29 while :latest - # published perfectly well. - tags: ${{ steps.reuse.outputs.build_ref }} - # The reuse key. Read back off the channel tag on the next push to - # decide whether that push needs to build at all, so this is not - # decoration — an unstamped image is one that will always rebuild. - labels: | - fc.revision=${{ steps.reuse.outputs.revision }} - # LOAD-BEARING, not a preference. On the default docker driver these - # were no-ops; on the docker-container driver above, - # build-push-action@v5 defaults provenance to TRUE when pushing. - # Provenance attaches an attestation manifest, which makes the pushed - # tag a manifest INDEX — and `.Image.Config.Labels` does not resolve - # through an index. - # - # The label directly above IS the reuse key. Wrap the channel tag in - # an index and the next push reads fc.revision=, misses, and - # rebuilds. Then so does the one after that, forever. Nothing fails, - # nothing goes red, and the only symptom is the bill. That is #3183 - # arriving through a different door, and note #3127 §4 records the - # same shape for `platforms:`. - provenance: false - sbom: false - # The ONLY cache this driver can have. `docker-container` gets a - # FRESH buildkit instance per job, so unlike the default docker - # driver it has no local layer store to fall back on — measured on - # run 4896, the first builds after the driver change: web 3m44s - # (was 2m23s), ml 3m49s (was 3m20s), agent 11m12s (was 9m26s). The - # driver change ALONE is a regression; this is the other half of it. - # - # mode=max so intermediate stages cache too. web's frontend-builder - # stage and the agent's two ~150s pip layers are the whole cost, and - # they are exactly what a min-mode cache would drop. - # - # A `:buildcache` tag is NOT the withdrawn tag scheme coming back. - # Rule 145 narrowed against names NOTHING reads; this one is read by - # every build that runs, is one moving ref per image rather than one - # per build, holds cache blobs rather than a shippable artifact, and - # is overwritten in place rather than accumulating. It is closer to - # :dev than to the :2026.8.28 tags milestone 318 deleted. (#3114.) - cache-from: type=registry,ref=git.fabledsword.com/bvandeusen/fabledcurator-ml:buildcache - cache-to: type=registry,ref=git.fabledsword.com/bvandeusen/fabledcurator-ml:buildcache,mode=max - - # Every tag but the channel's own is written HERE, registry-side, - # whether or not a build ran. Each -t becomes another reference to the - # SAME manifest the channel tag holds, so :c- is byte-identical to - # what is published rather than a lookalike rebuild. - # - # Owning the build path too is #3190's fix, not a tidy-up: - # - # #27 pushing …/fabledcurator:latest DONE 15.8s - # #28 pushing …/fabledcurator:c-0e15c44 with docker - # #28 ERROR: tag does not exist: …:c-0e15c44 - # - # Intermittent — build-ml made the identical two-tag push seconds later - # and succeeded — and worse than it looks. `:latest` had already - # published, so production was correct while the immutable rollback tag - # rule 145 requires of every main push simply did not exist. Nothing but - # the red job would ever have noticed: a missing :c- has no - # consumer that fails, so it surfaces when somebody needs to roll back. - # - # `imagetools create` is a registry-side manifest copy — no layer - # transfer, no local daemon, nothing that can be absent. The reuse case - # has always gone this way, so this puts the build case on the code that - # was already proven rather than on a second path. - # - # Running on every path also keeps family rule 146 true: a rolling - # channel refreshes itself, so skipping a build must never leave :dev or - # :latest pointing at something older than the commit just pushed. - # - # The cost, accepted knowingly: `imagetools create` wraps its source in - # an index, so :c- becomes an index and fc.revision does not - # resolve through it. Nothing reads that label off :c- — the reuse - # check only ever inspects the CHANNEL tag — and the index names the - # same manifest, so a pull is byte-identical. The reuse path already - # produced :c- this way; this only makes it uniform. - - name: Write the remaining tags from the published image - env: - IMAGE: git.fabledsword.com/bvandeusen/fabledcurator-ml - CHANNEL_REF: ${{ steps.reuse.outputs.channel_ref }} - # Empty when no build ran this job (a reuse hit, or the step's `if:` - # skipped it). Non-empty means THIS run pushed that manifest. - BUILT_DIGEST: ${{ steps.build.outputs.digest }} - TAGS: ${{ steps.tag.outputs.tags }} - run: | - set -euf - # WHAT WE COPY FROM, which is not what we EXCLUDE (#4290). - # - # This step used to copy from the channel tag by NAME. Nothing - # serialises builds — there is no `concurrency:` key anywhere in - # .forgejo/workflows/ — so two pushes to one branch run in full - # parallel, both miss the reuse check, and both build. If the OLDER - # one finishes last it wins the channel tag; and then its repoint - # step, reading that tag by name, wrote :c- from whatever the - # other run had just published. An immutable rollback tag (rule 145) - # naming a different commit's bytes, wrong from birth — and - # immutability then guarantees nobody ever corrects it. Nothing goes - # red; it surfaces the day someone needs to roll back. - # - # So when this job built, copy from the DIGEST it pushed. Correct - # whatever a concurrent run does to the tag, and it does not depend - # on the runner honouring a `concurrency:` key — which this file has - # already been burned by once (the `format()` note at the top: an - # expression that evaluated false with no symptom at all). - # - # On a reuse hit there is no digest, and the channel tag is still the - # right source: "hit" MEANS that tag already carries this commit's - # fc.revision, which the reuse step verified by reading it. - if [ -n "${BUILT_DIGEST:-}" ]; then - SOURCE="$IMAGE@$BUILT_DIGEST" - echo "repoint: copying the digest this run published: $SOURCE" - else - SOURCE="$CHANNEL_REF" - echo "repoint: no build this run (reuse hit) — copying from $SOURCE" - fi - # The source tag is EXCLUDED from the targets, and that is load- - # bearing rather than an optimisation. - # - # `imagetools create` wraps the source manifest in an INDEX. Point it - # at the channel tag with that same tag as a target and the tag stops - # being a plain image — after which `.Image.Config.Labels` no longer - # resolves through it and the fc.revision label reads as absent. The - # next push then misses and rebuilds, so reuse worked exactly once - # and every subsequent push paid full price. Observed on run 4751: - # ml:dev reported fc.revision= one push after run 4749 had read - # a7e626a67a79 off it. Nothing failed; the savings just evaporated. - # - # Excluding the source means the channel tag is only ever written - # by a real build, so it stays a plain image and stays readable. - # On dev that leaves nothing to do either way: the build pushed :dev - # itself, or the hit established it was already right. On main it - # leaves :c-, which rule 145 requires of every main push whether - # or not a build ran. - # - # steps.tag emits ONE comma-separated list; imagetools wants a -t per - # ref. (That list used to feed docker/build-push-action directly — - # which is exactly what #3190 made unsafe.) - ARGS="" - IFS=, + # The layers were just built on this runner pool, so the pull is + # normally local. The push sends only blobs the destination lacks — + # whether the registry shares them across two repositories of one + # owner is not something this file should assume, and the first run's + # output answers it. Either way it moves no more bytes than the + # duplicate build's own push did, and it compiles nothing. + docker pull "$SRC@$DIGEST" for t in $TAGS; do - # Keyed on CHANNEL_REF, never on SOURCE. SOURCE may now be a digest - # ref, which never equals a tag string — testing against it would - # stop excluding the channel tag, imagetools would index-wrap it, - # and `.Image.Config.Labels` would stop resolving through it. That - # kills the reuse label permanently (see the note just below). - [ "$t" = "$CHANNEL_REF" ] && continue - ARGS="$ARGS -t $t" + 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:-}" + 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 - unset IFS - if [ -z "$ARGS" ]; then - echo "repoint: $CHANNEL_REF is the only tag for this channel and" - echo "repoint: already holds this revision — nothing to write." - exit 0 - fi - # shellcheck disable=SC2086 - docker buildx imagetools create $ARGS "$SOURCE" - echo "repointed from $SOURCE:$ARGS" - # The desktop GPU agent (#114) — published so the operator pulls + runs it on - # the GPU machine instead of building locally. Independent of web/ml (its own - # CUDA + onnxruntime-gpu image, context = agent/). Same tag cadence. build-agent: runs-on: python-ci container: