From 36f0f97ae8247285e647910fbae719ba45dc6dda Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sat, 12 Sep 2026 14:52:36 -0400 Subject: [PATCH] ci(docker-publish): serialise publishes so two runs cannot race :latest Three triggers feed this workflow -- tag push, cli-release dispatch and manual dispatch -- and nothing stopped them overlapping. On 2026-09-12 two dispatches for 1.3.6 ran at once (17:02:37 and 17:04:52) and one had to be cancelled by hand. Both would have pushed :X.Y.Z, :X.Y, :X and :latest. The last writer of :latest wins, so a slower run of an OLDER version can land on top of a newer one and silently become what every `docker pull nself/nself-admin` receives. #105 had just removed release.yml's ungated publisher for the same class of reason; this is the same problem one layer up, between two runs of the gated publisher itself. The group is a constant, not the version. Serialising per-version would still let 1.3.6 and 1.3.7 race, and the shared floating tags are exactly what they contend for. One image, one publisher. cancel-in-progress is false deliberately. Cancelling a publish mid-push is not a safe stop -- that is what happened to release.yml on v1.3.6, killed partway through pushing with the manifest unfinished. A queued run costs minutes; a half-pushed manifest is a broken :latest. Trade-off worth stating: GitHub keeps only one pending run per group, so a third request cancels the second while it waits. That is correct for the duplicate-dispatch case this fixes (same version twice -- drop the redundant one). For three distinct versions queued at once it would drop the middle; the Hygiene release-tag gate catches a version whose image never published, and releases here are sequential and rare. Pure addition: no existing key changed. Verified the workflow still parses with all three triggers and the docker-publish job intact. --- .github/workflows/docker-publish.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c54af4d6..3fcac0be 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -28,6 +28,34 @@ on: required: true type: string +# Only one publish of this image at a time. +# +# Three triggers feed this workflow and nothing stopped them overlapping. On +# 2026-09-12 two dispatches for 1.3.6 ran simultaneously (17:02:37 and +# 17:04:52); one was cancelled by hand. Both would have pushed :X.Y.Z, :X.Y, +# :X and :latest, and the last writer of :latest wins — so a slower run of an +# OLDER version can land on top of a newer one and quietly become what every +# `docker pull nself/nself-admin` gets. +# +# The group is deliberately a constant rather than the version. Serialising +# per-version would still let 1.3.6 and 1.3.7 race, and it is precisely the +# shared floating tags they contend for. One image, one publisher. +# +# cancel-in-progress is false on purpose. Cancelling a publish mid-push is not +# a safe stop: it is what happened to release.yml on v1.3.6, which was killed +# partway through pushing and left the manifest unfinished. A queued run costs +# a few minutes; a half-pushed manifest is a broken :latest. +# +# Known trade-off: GitHub keeps only ONE pending run per group, so a third +# request cancels the second while it waits. That is the right outcome for the +# duplicate-dispatch case this fixes (same version twice — the redundant one +# should go). For distinct versions queued three deep it would drop the middle +# one; the Hygiene release-tag gate catches a version whose image never +# published, and releases here are sequential and rare. +concurrency: + group: docker-publish-nself-admin + cancel-in-progress: false + env: DOCKER_IMAGE: nself/nself-admin