From 01f580513969815649f174415f89f391958d4620 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 19 Jul 2026 12:52:29 -0400 Subject: [PATCH] fix(ci): serialize CI per ref + tag images at build time (fd race #1093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish lane raced on the runner's shared docker daemon when two dev pushes landed seconds apart: a concurrent run evicted the freshly-built : image mid-push, so the post-push `docker tag : :dev` failed with "No such image" (a flake — a lone re-run went green). Two guards, both matching CI-runner's canonical build workflows: - Workflow-level `concurrency: ci-${{ github.ref }}` with cancel-in-progress:false serializes runs per ref, so no two publishes ever share the daemon. false (not true) because rule 46 requires every push to emit its own immutable : image — a superseded run must still finish. - Build both the : and moving (:dev/:latest) tags in one `docker build -t ... -t ...`, then push each. Removes the fragile post-push `docker tag` of an image a concurrent run could have evicted. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CAGR73DUowdVFVvYzLXC5C --- .forgejo/workflows/ci.yml | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 800746e..5a973bf 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -14,6 +14,17 @@ on: # pull_request intentionally absent — push on [dev, main] already fires CI for # every dev commit and dev→main PRs. Single-operator repo, no fork PRs. +# Serialize CI runs per ref so the publish lane never shares the runner's docker +# daemon with another run. Two publishes racing on one daemon evict each other's +# freshly-built image mid-push, breaking the post-build tag/push (issue #1093). +# cancel-in-progress:false is deliberate — rule 46 requires EVERY dev/main push to +# publish its own immutable : image, so a superseded run must still run to +# completion (never cancelled) to emit its SHA. (Forgejo honors `concurrency:` — +# CI-runner's build workflows use it.) +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: false + jobs: # Fast-fail lint lane. ruff is pre-installed in the ci-python image, so this # runs with NO dependency install and surfaces lint bounces in seconds. @@ -115,14 +126,25 @@ jobs: run: | set -euxo pipefail IMAGE=git.fabledsword.com/bvandeusen/steward - docker build -t "$IMAGE:${{ github.sha }}" . - docker push "$IMAGE:${{ github.sha }}" + # The moving tag for this ref: dev→:dev, main→:latest (rule 46). main IS + # the production line, so :latest tracks main's tip; there is no :main. + MOVING="" if [ "${{ github.ref }}" = "refs/heads/dev" ]; then - docker tag "$IMAGE:${{ github.sha }}" "$IMAGE:dev" - docker push "$IMAGE:dev" + MOVING="dev" elif [ "${{ github.ref }}" = "refs/heads/main" ]; then - docker tag "$IMAGE:${{ github.sha }}" "$IMAGE:latest" - docker push "$IMAGE:latest" + MOVING="latest" + fi + # Tag BOTH targets at build time (mirrors CI-runner's build workflows): + # one `docker build -t : -t :` points both tags at the image + # atomically, so we never run a post-push `docker tag` of an image a + # concurrent run could have evicted from the shared daemon (issue #1093). + if [ -n "$MOVING" ]; then + docker build -t "$IMAGE:${{ github.sha }}" -t "$IMAGE:$MOVING" . + docker push "$IMAGE:${{ github.sha }}" + docker push "$IMAGE:$MOVING" + else + docker build -t "$IMAGE:${{ github.sha }}" . + docker push "$IMAGE:${{ github.sha }}" fi - name: Prune dangling layers if: always()